Sfoglia il codice sorgente

fix(core,cli): restore provider compatibility and windows studio launch

Ma 6 mesi fa
parent
commit
97f2a32ef3

+ 25 - 0
packages/cli/src/__tests__/doctor.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { buildDoctorModelCandidates } from "../commands/doctor.js";
+
+describe("doctor model candidate probing", () => {
+  it("keeps the configured model first, then tries discovered models without duplicates", () => {
+    const candidates = buildDoctorModelCandidates("google/gemma-4-26b-4b", [
+      { id: "google/gemma-4-26b-4b", name: "google/gemma-4-26b-4b" },
+      { id: "google/gemma-4-27b-it", name: "google/gemma-4-27b-it" },
+      { id: "gemini-2.5-flash", name: "gemini-2.5-flash" },
+    ]);
+
+    expect(candidates[0]).toBe("google/gemma-4-26b-4b");
+    expect(candidates[1]).toBe("google/gemma-4-27b-it");
+    expect(candidates[2]).toBe("gemini-2.5-flash");
+    expect(new Set(candidates).size).toBe(candidates.length);
+  });
+
+  it("falls back to common compatibility candidates when /models is unavailable", () => {
+    const candidates = buildDoctorModelCandidates("missing-model", []);
+    expect(candidates[0]).toBe("missing-model");
+    expect(candidates).toContain("gpt-5.4");
+    expect(candidates).toContain("MiniMax-M2.7");
+    expect(candidates).toContain("gemini-2.5-flash");
+  });
+});

+ 6 - 0
packages/cli/src/__tests__/studio-runtime.test.ts

@@ -56,6 +56,12 @@ describe("studio runtime resolution", () => {
     });
   });
 
+  it("uses a file URL for the tsx loader on Windows absolute paths", async () => {
+    const { toNodeImportSpecifier } = await import("../commands/studio.js");
+    expect(toNodeImportSpecifier("D:\\repo\\packages\\studio\\node_modules\\tsx\\dist\\loader.mjs"))
+      .toBe("file:///D:/repo/packages/studio/node_modules/tsx/dist/loader.mjs");
+  });
+
   it("finds monorepo packages/studio sources from a project directory", async () => {
     accessMock.mockImplementation(async (path: string) => {
       if (path === tsSourceEntry) {

+ 69 - 14
packages/cli/src/commands/doctor.ts

@@ -35,6 +35,49 @@ function buildDoctorProbePlans(
   return plans;
 }
 
+export function buildDoctorModelCandidates(
+  preferredModel: string | undefined,
+  discoveredModels: Array<{ id: string; name: string }>,
+): string[] {
+  const seen = new Set<string>();
+  const candidates: string[] = [];
+  const push = (value: string | undefined | null) => {
+    if (!value || value.trim().length === 0) return;
+    const model = value.trim();
+    if (seen.has(model)) return;
+    seen.add(model);
+    candidates.push(model);
+  };
+
+  push(preferredModel);
+  for (const model of discoveredModels) push(model.id);
+  push("gpt-5.4");
+  push("gpt-4o");
+  push("claude-sonnet-4-6");
+  push("MiniMax-M2.7");
+  push("kimi-k2.5");
+  push("gemini-2.5-flash");
+  return candidates;
+}
+
+async function fetchDoctorModels(
+  baseUrl: string,
+  apiKey: string,
+): Promise<Array<{ id: string; name: string }>> {
+  const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+  try {
+    const res = await fetch(modelsUrl, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(10_000),
+    });
+    if (!res.ok) return [];
+    const json = await res.json() as { data?: Array<{ id: string }> };
+    return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
+  } catch {
+    return [];
+  }
+}
+
 export const doctorCommand = new Command("doctor")
   .description("Check environment and project health")
   .option("--repair-node-runtime", "Write .nvmrc and .node-version pinned to Node 22 for this project")
@@ -220,26 +263,38 @@ export const doctorCommand = new Command("doctor")
         let connected = false;
         let detectedDetail = "";
         let lastError = "Unknown error";
+        const discoveredModels = (llmConfig.provider === "openai" && llmConfig.apiKey && llmConfig.baseUrl)
+          ? await fetchDoctorModels(llmConfig.baseUrl, llmConfig.apiKey)
+          : [];
+        const modelCandidates = llmConfig.provider === "openai"
+          ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
+          : [llmConfig.model];
         const plans = llmConfig.provider === "openai"
           ? buildDoctorProbePlans(llmConfig.apiFormat, llmConfig.stream)
           : [{ apiFormat: (llmConfig.apiFormat ?? "chat") as "chat" | "responses", stream: llmConfig.stream ?? true }];
 
-        for (const plan of plans) {
-          try {
-            const client = createLLMClient({
-              ...llmConfig,
-              apiFormat: plan.apiFormat,
-              stream: plan.stream,
-            });
-            const response = await chatCompletion(client, llmConfig.model, [
-              { role: "user", content: "Say OK" },
-            ], { maxTokens: 16 });
+        for (const model of modelCandidates) {
+          for (const plan of plans) {
+            try {
+              const client = createLLMClient({
+                ...llmConfig,
+                model,
+                apiFormat: plan.apiFormat,
+                stream: plan.stream,
+              });
+              const response = await chatCompletion(client, model, [
+                { role: "user", content: "Say OK" },
+              ], { maxTokens: 16 });
 
-            connected = true;
-            detectedDetail = `OK (model: ${llmConfig.model}, apiFormat=${plan.apiFormat}, stream=${plan.stream}, tokens: ${response.usage.totalTokens})`;
+              connected = true;
+              detectedDetail = `OK (model: ${model}, apiFormat=${plan.apiFormat}, stream=${plan.stream}, tokens: ${response.usage.totalTokens})`;
+              break;
+            } catch (error) {
+              lastError = error instanceof Error ? error.message : String(error);
+            }
+          }
+          if (connected) {
             break;
-          } catch (error) {
-            lastError = error instanceof Error ? error.message : String(error);
           }
         }
 

+ 9 - 2
packages/cli/src/commands/studio.ts

@@ -3,7 +3,7 @@ import { findProjectRoot, log, logError } from "../utils.js";
 import { spawn } from "node:child_process";
 import { dirname, join } from "node:path";
 import { access } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
 import { ensureProjectDirectoryInitialized } from "../project-bootstrap.js";
 
 export interface StudioLaunchSpec {
@@ -40,6 +40,13 @@ async function firstAccessiblePath(paths: readonly string[]): Promise<string | u
 
 const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
 
+export function toNodeImportSpecifier(path: string): string {
+  if (/^[A-Za-z]:[\\/]/.test(path)) {
+    return `file:///${path.replace(/\\/g, "/")}`;
+  }
+  return path;
+}
+
 export function resolveBrowserLaunch(
   platform: NodeJS.Platform,
   url: string,
@@ -68,7 +75,7 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
       return {
         studioEntry: sourceEntry,
         command: "node",
-        args: ["--import", localTsxLoader, sourceEntry, root],
+        args: ["--import", toNodeImportSpecifier(localTsxLoader), sourceEntry, root],
       };
     }
 

+ 189 - 0
packages/core/src/__tests__/architect.test.ts

@@ -516,4 +516,193 @@ describe("ArchitectAgent", () => {
 
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
   });
+
+  it("passes maxTokens 16384 when generating foundation", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "max-tokens-book",
+      title: "Max Tokens Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundation(book);
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.8, maxTokens: 16384 }),
+    );
+  });
+
+  it("passes maxTokens 16384 when generating foundation from import", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "import-max-tokens-book",
+      title: "Import Max Tokens Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundationFromImport(book, "第一章正文");
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.5, maxTokens: 16384 }),
+    );
+  });
+
+  it("passes maxTokens 16384 when generating fanfic foundation", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "fanfic-max-tokens-book",
+      title: "Fanfic Max Tokens Book",
+      platform: "other",
+      genre: "fanfic",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFanficFoundation(book, "正典文本", "canon");
+
+    expect(chatSpy).toHaveBeenCalledWith(
+      expect.any(Array),
+      expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }),
+    );
+  });
 });

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

@@ -111,6 +111,7 @@ function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClien
   return {
     provider: "openai",
     service: "openai",
+    configSource: "studio",
     apiFormat: "chat",
     stream: true,
     _piModel: MOCK_PI_MODEL,
@@ -287,6 +288,31 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
   });
 
+  it("keeps legacy env custom openai-compatible chat on pi-ai path", async () => {
+    const fetchMock = vi.fn();
+    vi.stubGlobal("fetch", fetchMock);
+    mockCompleteSimple.mockResolvedValue(makeAssistantMessage("legacy ok"));
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      configSource: "env",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      },
+    });
+
+    const result = await chatCompletion(client, "gemma-4", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("legacy ok");
+    expect(mockCompleteSimple).toHaveBeenCalledOnce();
+    expect(fetchMock).not.toHaveBeenCalled();
+
+    vi.unstubAllGlobals();
+  });
+
   it("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,

+ 32 - 1
packages/core/src/__tests__/service-resolver.test.ts

@@ -5,12 +5,26 @@ import { join } from "node:path";
 import { tmpdir } from "node:os";
 
 // Models that exist in pi-ai's built-in registry (simulated)
-const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5"]);
+const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5", "MiniMax-M2.7"]);
 
 // Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
 vi.mock("@mariozechner/pi-ai", () => ({
   getModel: vi.fn((provider: string, modelId: string) => {
     if (!KNOWN_MODELS.has(modelId)) return undefined;
+    if (modelId === "MiniMax-M2.7") {
+      return {
+        id: modelId,
+        name: modelId,
+        api: "anthropic-messages",
+        provider: "minimax",
+        baseUrl: "https://api.minimax.io/anthropic",
+        reasoning: true,
+        input: ["text"],
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+        contextWindow: 204800,
+        maxTokens: 131072,
+      };
+    }
     return {
       id: modelId,
       name: modelId,
@@ -139,4 +153,21 @@ describe("resolveServiceModel", () => {
     expect(result.model.id).toBe("gpt-5.4");
     expect(result.model.api).toBe("openai-responses");
   });
+
+  it("overrides MiniMax pi-ai metadata with our preset baseUrl and api", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }),
+    );
+
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    expect(result.apiKey).toBe("sk-minimax");
+    expect(result.model.baseUrl).toBe("https://api.minimax.io/v1");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.reasoning).toBe(true);
+    expect(result.model.contextWindow).toBe(204800);
+    expect(result.model.maxTokens).toBe(131072);
+  });
 });

+ 3 - 3
packages/core/src/agents/architect.ts

@@ -279,7 +279,7 @@ ${finalRequirementsPrompt}`;
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt },
       { role: "user", content: userMessage },
-    ], { temperature: 0.8 });
+    ], { temperature: 0.8, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }
@@ -676,7 +676,7 @@ ${keyPrinciplesPrompt}`;
         role: "user",
         content: userMessage,
       },
-    ], { temperature: 0.5 });
+    ], { temperature: 0.5, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }
@@ -765,7 +765,7 @@ prohibitions:
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
-    ], { temperature: 0.7 });
+    ], { temperature: 0.7, maxTokens: 16384 });
 
     return this.parseSections(response.content);
   }

+ 5 - 1
packages/core/src/llm/provider.ts

@@ -86,6 +86,7 @@ export interface LLMMessage {
 export interface LLMClient {
   readonly provider: "openai" | "anthropic";
   readonly service?: string;
+  readonly configSource?: LLMConfig["configSource"];
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly _piModel?: PiModel<PiApi>;
@@ -164,6 +165,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   return {
     provider,
     service: serviceName,
+    configSource: config.configSource,
     apiFormat,
     stream,
     _piModel: piModel,
@@ -308,7 +310,9 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
 }
 
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
-  return client.service === "custom" && (client.provider === "openai" || client.provider === "anthropic");
+  return client.configSource === "studio"
+    && client.service === "custom"
+    && (client.provider === "openai" || client.provider === "anthropic");
 }
 
 function buildCustomHeaders(client: LLMClient): Record<string, string> {

+ 1 - 1
packages/core/src/llm/service-presets.ts

@@ -13,7 +13,7 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
   anthropic:   { api: "anthropic-messages",  baseUrl: "https://api.anthropic.com",                         label: "Anthropic",       temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
   deepseek:    { api: "openai-completions",  baseUrl: "https://api.deepseek.com",                          label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
   moonshot:    { api: "openai-completions",  baseUrl: "https://api.moonshot.cn/v1",                        label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
-  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimax.chat/v1",                       label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
+  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimax.io/v1",                         label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
   bailian:     { api: "openai-completions",  baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0 },
   zhipu:       { api: "openai-completions",  baseUrl: "https://open.bigmodel.cn/api/paas/v4",              label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95 },
   siliconflow: { api: "openai-completions",  baseUrl: "https://api.siliconflow.cn/v1",                     label: "硅基流动" },

+ 23 - 24
packages/core/src/llm/service-resolver.ts

@@ -30,35 +30,34 @@ export async function resolveServiceModel(
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const preset = resolveServicePreset(baseService);
   const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
+  const apiType = service.startsWith("custom:")
+    ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
+    : (preset?.api ?? "openai-completions");
+  const configuredBaseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
 
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
-  let model = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
+  const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
+  const effectiveBaseUrl = configuredBaseUrl || piModel?.baseUrl || "";
 
-  if (!model) {
-    // Construct a Model object from service preset for models not in pi-ai's registry
-    const apiType = service.startsWith("custom:")
-      ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
-      : (preset?.api ?? "openai-completions");
-    const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
-    if (!baseUrl) {
-      throw new Error(
-        `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
-      );
-    }
-    model = {
-      id: modelId,
-      name: modelId,
-      api: apiType as Api,
-      provider: piProvider,
-      baseUrl,
-      reasoning: false,
-      input: ["text"] as ("text" | "image")[],
-      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
-      contextWindow: 0,
-      maxTokens: 16384,
-    };
+  if (!effectiveBaseUrl) {
+    throw new Error(
+      `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
+    );
   }
 
+  const model: Model<Api> = {
+    id: modelId,
+    name: piModel?.name ?? modelId,
+    api: apiType as Api,
+    provider: piProvider,
+    baseUrl: effectiveBaseUrl,
+    reasoning: piModel?.reasoning ?? false,
+    input: piModel?.input ?? ["text"] as ("text" | "image")[],
+    cost: piModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    contextWindow: piModel?.contextWindow ?? 0,
+    maxTokens: piModel?.maxTokens ?? 16384,
+  };
+
   return {
     model,
     apiKey,