Преглед изворни кода

Merge issue-211-plus-fixes

Ma пре 6 месеци
родитељ
комит
60b3f142d7

+ 2 - 1
packages/cli/src/commands/config.ts

@@ -24,8 +24,9 @@ configCommand
 
 
       const KNOWN_KEYS = new Set([
       const KNOWN_KEYS = new Set([
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
-        "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
+        "llm.thinkingBudget", "llm.proxyUrl", "llm.apiFormat", "llm.stream",
         "inputGovernanceMode",
         "inputGovernanceMode",
+        "foundation.reviewRetries",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",

+ 5 - 3
packages/cli/src/commands/doctor.ts

@@ -2,6 +2,7 @@ import { Command } from "commander";
 import { readFile } from "node:fs/promises";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
 import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
+import { fetchWithProxy } from "@actalk/inkos-core";
 import {
 import {
   ensureNodeRuntimePinFiles,
   ensureNodeRuntimePinFiles,
   evaluateSqliteMemorySupport,
   evaluateSqliteMemorySupport,
@@ -74,13 +75,14 @@ export function resolveDoctorModelsBaseUrl(
 async function fetchDoctorModels(
 async function fetchDoctorModels(
   modelsBaseUrl: string,
   modelsBaseUrl: string,
   apiKey: string,
   apiKey: string,
+  proxyUrl?: string,
 ): Promise<Array<{ id: string; name: string }>> {
 ): Promise<Array<{ id: string; name: string }>> {
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
   try {
-    const res = await fetch(modelsUrl, {
+    const res = await fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) return [];
     if (!res.ok) return [];
     const json = await res.json() as { data?: Array<{ id: string }> };
     const json = await res.json() as { data?: Array<{ id: string }> };
     return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
     return (json.data ?? []).map((model) => ({ id: model.id, name: model.id }));
@@ -284,7 +286,7 @@ export const doctorCommand = new Command("doctor")
           resolveServiceModelsBaseUrl,
           resolveServiceModelsBaseUrl,
         );
         );
         const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
         const discoveredModels = (llmConfig.apiKey && modelsBaseUrl)
-          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey)
+          ? await fetchDoctorModels(modelsBaseUrl, llmConfig.apiKey, llmConfig.proxyUrl)
           : [];
           : [];
         const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
         const modelCandidates = (llmConfig.provider === "openai" || discoveredModels.length > 0)
           ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)
           ? buildDoctorModelCandidates(llmConfig.model, discoveredModels)

+ 1 - 0
packages/cli/src/utils.ts

@@ -147,6 +147,7 @@ export function buildPipelineConfig(
     model: config.llm.model,
     model: config.llm.model,
     projectRoot: root,
     projectRoot: root,
     defaultLLMConfig: config.llm,
     defaultLLMConfig: config.llm,
+    foundationReviewRetries: config.foundation.reviewRetries,
     modelOverrides: config.modelOverrides,
     modelOverrides: config.modelOverrides,
     inputGovernanceMode: extra?.inputGovernanceMode ?? config.inputGovernanceMode,
     inputGovernanceMode: extra?.inputGovernanceMode ?? config.inputGovernanceMode,
     notifyChannels: extra?.notifyChannels ?? config.notify,
     notifyChannels: extra?.notifyChannels ?? config.notify,

+ 1 - 0
packages/core/package.json

@@ -50,6 +50,7 @@
     "dotenv": "^16.4.0",
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
     "epub-gen-memory": "^1.0.10",
     "js-yaml": "^4.1.1",
     "js-yaml": "^4.1.1",
+    "undici": "6.21.3",
     "zod": "^3.25.76"
     "zod": "^3.25.76"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 15 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -160,6 +160,21 @@ describe("agent deterministic writing tools", () => {
     expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
     expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
   });
   });
 
 
+  it("surfaces writer sub-agent pipeline failures as tool errors", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => {
+        throw new Error("disk write failed");
+      }),
+    };
+    const tool = createSubAgentTool(pipeline as never, "harbor");
+
+    await expect(tool.execute("tool-writer-fails", {
+      agent: "writer",
+      bookId: "harbor",
+      instruction: "继续写下一章",
+    } as any)).rejects.toThrow("disk write failed");
+  });
+
   it("uses the active book for writer when bookId is omitted", async () => {
   it("uses the active book for writer when bookId is omitted", async () => {
     const pipeline = {
     const pipeline = {
       writeNextChapter: vi.fn(async () => ({
       writeNextChapter: vi.fn(async () => ({

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

@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
 import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
 import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
-import { PipelineRunner } from "../pipeline/runner.js";
+import { buildImportFoundationSource, PipelineRunner } from "../pipeline/runner.js";
 import * as llmProvider from "../llm/provider.js";
 import * as llmProvider from "../llm/provider.js";
 import { StateManager } from "../state/manager.js";
 import { StateManager } from "../state/manager.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { ArchitectAgent } from "../agents/architect.js";
@@ -41,6 +41,34 @@ const ZERO_USAGE = {
   totalTokens: 0,
   totalTokens: 0,
 } as const;
 } as const;
 
 
+describe("buildImportFoundationSource", () => {
+  it("compacts large imported books into opening, middle anchors, ending, and title catalog", () => {
+    const chapters = Array.from({ length: 36 }, (_, index) => {
+      const n = index + 1;
+      return {
+        title: `第${n}章 标题${n}`,
+        content: `OPEN-${n}\n${"正文".repeat(3000)}\nTAIL-${n}`,
+      };
+    });
+    const fullText = chapters.map((chapter, index) => `第${index + 1}章 ${chapter.title}\n\n${chapter.content}`).join("\n\n---\n\n");
+
+    const source = buildImportFoundationSource(chapters, "zh", {
+      maxFullTextChars: 20_000,
+      chapterExcerptChars: 1_200,
+      titleCatalogChars: 2_000,
+    });
+
+    expect(source.length).toBeLessThan(fullText.length / 2);
+    expect(source).toContain("压缩资料包");
+    expect(source).toContain("完整章节将在后续顺序回放");
+    expect(source).toContain("第1章 第1章 标题1");
+    expect(source).toContain("第36章 第36章 标题36");
+    expect(source).toContain("OPEN-1");
+    expect(source).toContain("TAIL-36");
+    expect(source).not.toContain("正文".repeat(2500));
+  });
+});
+
 const CRITICAL_ISSUE: AuditIssue = {
 const CRITICAL_ISSUE: AuditIssue = {
   severity: "critical",
   severity: "critical",
   category: "continuity",
   category: "continuity",
@@ -551,6 +579,68 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("honors configured foundation review retry count before accepting a rejected foundation", async () => {
+    const { root, runner, bookId } = await createRunnerFixture({
+      foundationReviewRetries: 4,
+    } as Partial<ConstructorParameters<typeof PipelineRunner>[0]>);
+    const reviewer = new FoundationReviewerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          thinkingBudget: 0,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId,
+    });
+    const foundation = {
+      storyBible: "# Story Bible",
+      volumeOutline: "# Volume Outline",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules",
+      currentState: "# Current State",
+      pendingHooks: "# Pending Hooks",
+    };
+    const generate = vi.fn(async (_reviewFeedback?: string) => foundation);
+    const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review);
+
+    reviewMock.mockReset();
+    reviewMock.mockResolvedValue({
+      passed: false,
+      totalScore: 72,
+      dimensions: [],
+      overallFeedback: "仍未达到可开写标准。",
+    });
+
+    try {
+      await (runner as unknown as {
+        generateAndReviewFoundation: (params: {
+          readonly generate: (reviewFeedback?: string) => Promise<typeof foundation>;
+          readonly reviewer: FoundationReviewerAgent;
+          readonly mode: "original";
+          readonly language: "zh";
+          readonly stageLanguage: "zh";
+        }) => Promise<typeof foundation>;
+      }).generateAndReviewFoundation({
+        generate,
+        reviewer,
+        mode: "original",
+        language: "zh",
+        stageLanguage: "zh",
+      });
+
+      expect(generate).toHaveBeenCalledTimes(5);
+      expect(reviewMock).toHaveBeenCalledTimes(5);
+      expect(generate.mock.calls[1]?.[0]).toContain("仍未达到可开写标准");
+      expect(generate.mock.calls[4]?.[0]).toContain("仍未达到可开写标准");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const { root, runner, bookId } = await createRunnerFixture();
 
 
@@ -3085,6 +3175,23 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("imports short style samples with a deterministic guide instead of failing", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockRejectedValue(new Error("should not call llm for short samples"));
+    const sample = "夜雨落在窗台。她没回头,只把那封信压进抽屉。楼下车灯一闪,像有人终于找到了这里。";
+
+    try {
+      const guide = await runner.generateStyleGuide(bookId, sample, "short-snippet");
+
+      expect(chatSpy).not.toHaveBeenCalled();
+      expect(guide).toContain("样本文本较短");
+      await expect(readFile(join(state.bookDir(bookId), "story", "style_profile.json"), "utf-8")).resolves.toContain("short-snippet");
+      await expect(readFile(join(state.bookDir(bookId), "story", "style_guide.md"), "utf-8")).resolves.toContain("样本文本较短");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("keeps canon import running when style guide extraction fails", async () => {
   it("keeps canon import running when style guide extraction fails", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
     const parentBookId = "parent-book";
     const parentBookId = "parent-book";

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

@@ -205,6 +205,18 @@ describe("chatCompletion via pi-ai", () => {
     expect(error.message).toContain("无法连接到 API 服务");
     expect(error.message).toContain("无法连接到 API 服务");
   });
   });
 
 
+  it("retries transient socket termination errors before failing the chapter pipeline", async () => {
+    mockStreamSimple
+      .mockReturnValueOnce(makeErrorStream("terminated: UND_ERR_SOCKET other side closed"))
+      .mockReturnValueOnce(makeTextStream("recovered"));
+
+    const client = makeClient();
+    const result = await chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("recovered");
+    expect(mockStreamSimple).toHaveBeenCalledTimes(2);
+  });
+
   it("passes temperature and maxTokens to streamSimple", async () => {
   it("passes temperature and maxTokens to streamSimple", async () => {
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
 
 
@@ -288,6 +300,38 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
     vi.unstubAllGlobals();
   });
   });
 
 
+  it("attaches a proxy dispatcher for custom openai-compatible chat when proxyUrl is configured", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { content: "proxied" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: false,
+      proxyUrl: "http://127.0.0.1:9910",
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "gpt-5.4", [{ role: "user", content: "nihao" }]);
+
+    expect(result.content).toBe("proxied");
+    expect(fetchMock).toHaveBeenCalledOnce();
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+      method: "POST",
+      dispatcher: expect.any(Object),
+    });
+
+    vi.unstubAllGlobals();
+  });
+
   it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
   it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
     const fetchMock = vi.fn().mockResolvedValue({
     const fetchMock = vi.fn().mockResolvedValue({
       ok: true,
       ok: true,

+ 75 - 0
packages/core/src/__tests__/proxy-fetch.test.ts

@@ -0,0 +1,75 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const proxyAgentMock = vi.fn((url: string) => ({ kind: "proxy-agent", url }));
+
+vi.mock("undici", () => ({
+  ProxyAgent: proxyAgentMock,
+}));
+
+describe("proxy fetch helpers", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.clearAllMocks();
+  });
+
+  it("prefers explicit llm proxyUrl over environment proxy variables", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const env = {
+      INKOS_LLM_PROXY_URL: "http://inkos-env-proxy:9910",
+      HTTPS_PROXY: "http://standard-proxy:9910",
+    };
+
+    expect(resolveProxyUrl("http://explicit-proxy:9910", env)).toBe("http://explicit-proxy:9910");
+    await fetchWithProxy("https://api.example/v1/chat/completions", { method: "POST" }, "http://explicit-proxy:9910", env);
+
+    expect(proxyAgentMock).toHaveBeenCalledWith("http://explicit-proxy:9910");
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/chat/completions",
+      expect.objectContaining({
+        method: "POST",
+        dispatcher: { kind: "proxy-agent", url: "http://explicit-proxy:9910" },
+      }),
+    );
+  });
+
+  it("uses INKOS_LLM_PROXY_URL before standard HTTPS_PROXY/HTTP_PROXY env vars", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const env = {
+      INKOS_LLM_PROXY_URL: "http://inkos-proxy:9910",
+      HTTPS_PROXY: "http://standard-proxy:9910",
+      HTTP_PROXY: "http://http-proxy:9910",
+    };
+
+    expect(resolveProxyUrl(undefined, env)).toBe("http://inkos-proxy:9910");
+    await fetchWithProxy("https://api.example/v1/models", {}, undefined, env);
+
+    expect(proxyAgentMock).toHaveBeenCalledWith("http://inkos-proxy:9910");
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/models",
+      expect.objectContaining({
+        dispatcher: { kind: "proxy-agent", url: "http://inkos-proxy:9910" },
+      }),
+    );
+  });
+
+  it("does not attach a dispatcher when no proxy is configured", async () => {
+    const { fetchWithProxy, resolveProxyUrl } = await import("../utils/proxy-fetch.js");
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+    vi.stubGlobal("fetch", fetchMock);
+
+    expect(resolveProxyUrl(undefined, {})).toBeUndefined();
+    await fetchWithProxy("https://api.example/v1/models", { headers: { Authorization: "Bearer test" } }, undefined, {});
+
+    expect(proxyAgentMock).not.toHaveBeenCalled();
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.example/v1/models",
+      { headers: { Authorization: "Bearer test" } },
+    );
+  });
+});

+ 45 - 0
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -83,6 +83,51 @@ describe("StateValidatorAgent", () => {
     expect(options?.maxTokens).toBeUndefined();
     expect(options?.maxTokens).toBeUndefined();
   });
   });
 
 
+  it("passes authority truth context into the cross-file validation prompt", async () => {
+    const agent = new StateValidatorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 8192,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(
+      agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
+      "chat",
+    ).mockResolvedValue({ content: "PASS", usage: ZERO_USAGE });
+
+    await agent.validate(
+      "正文确认:第五条规则才是天黑后不准出宿舍。",
+      2,
+      "old state",
+      "new state: 第一条规则已被批注",
+      "old hooks",
+      "new hooks",
+      "zh",
+      {
+        storyFrame: "简介里写过:规则一:天黑后不准出宿舍。",
+        bookRules: "硬规则:规则编号必须以前文正文确立版本为准。",
+        chapterSummaries: "第1章:发现第五条规则的漏洞。",
+      },
+    );
+
+    const messages = chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("truth files");
+    expect(messages[0]?.content).toContain("numbered");
+    expect(messages[1]?.content).toContain("## Authority / Cross-Truth Context");
+    expect(messages[1]?.content).toContain("规则一:天黑后不准出宿舍");
+    expect(messages[1]?.content).toContain("第1章:发现第五条规则的漏洞");
+  });
+
   it("throws when the validator model returns an empty response", async () => {
   it("throws when the validator model returns an empty response", async () => {
     const agent = new StateValidatorAgent({
     const agent = new StateValidatorAgent({
       client: {
       client: {

+ 16 - 0
packages/core/src/__tests__/verify-service.test.ts

@@ -47,6 +47,22 @@ describe("verifyService (B9)", () => {
     expect(result.probe.error).toContain("ECONNREFUSED");
     expect(result.probe.error).toContain("ECONNREFUSED");
   });
   });
 
 
+  it("probe 使用显式 proxyUrl 连接 /models", async () => {
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "gpt-4o" }] }),
+      } as any)
+      .mockRejectedValue(new Error("test: no real chat backend"));
+    global.fetch = fetchMock as typeof fetch;
+
+    const result = await verifyService("openai", "sk-test", { proxyUrl: "http://127.0.0.1:9910" });
+    expect(result.probe.ok).toBe(true);
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+      dispatcher: expect.any(Object),
+    });
+  });
+
   it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
   it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
     // custom 没 checkModel,verifyService 跳过 chat step
     // custom 没 checkModel,verifyService 跳过 chat step
     const result = await verifyService("custom", "sk-x");
     const result = await verifyService("custom", "sk-x");

+ 9 - 1
packages/core/src/agent/agent-tools.ts

@@ -190,6 +190,14 @@ export function createSubAgentTool(
             return textResult(
             return textResult(
               `Chapter written for "${targetBookId}". ` +
               `Chapter written for "${targetBookId}". ` +
               `Word count: ${(result as any).wordCount ?? "unknown"}.`,
               `Word count: ${(result as any).wordCount ?? "unknown"}.`,
+              {
+                kind: "chapter_written",
+                bookId: targetBookId,
+                chapterNumber: (result as any).chapterNumber,
+                title: (result as any).title,
+                wordCount: (result as any).wordCount,
+                status: (result as any).status,
+              },
             );
             );
           }
           }
 
 
@@ -240,7 +248,7 @@ export function createSubAgentTool(
         }
         }
       } catch (err: any) {
       } catch (err: any) {
         console.error(`[sub_agent] "${agent}" failed:`, err);
         console.error(`[sub_agent] "${agent}" failed:`, err);
-        return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
+        throw err;
       }
       }
     },
     },
   };
   };

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

@@ -944,7 +944,7 @@ ${continuationDirective}
 ## Output contract
 ## Output contract
 Follow the consolidated 5-section === SECTION: === layout: story_frame, volume_map, roles, book_rules, pending_hooks. Do NOT emit rhythm_principles or current_state — rhythm principles live in the last paragraph of volume_map; character initial status lives in roles.Current_State; initial hooks live in pending_hooks start_chapter=0 rows; era / setting anchors (only when the genre pins to a real year) are woven into story_frame's world-tonal-ground paragraph.
 Follow the consolidated 5-section === SECTION: === layout: story_frame, volume_map, roles, book_rules, pending_hooks. Do NOT emit rhythm_principles or current_state — rhythm principles live in the last paragraph of volume_map; character initial status lives in roles.Current_State; initial hooks live in pending_hooks start_chapter=0 rows; era / setting anchors (only when the genre pins to a real year) are woven into story_frame's world-tonal-ground paragraph.
 
 
-All prose must be derived from the source text. Do not invent settings. For volume_map, treat existing chapters as "review" (one paragraph) and continuation as prose chapter-level planning. Hook extraction must be complete (every unresolved clue).
+All prose must be derived from the source package. Do not invent settings. If the package says it is compressed, treat chapter catalog + excerpts as evidence for the foundation; the full chapters will be replayed later for detailed truth files. For volume_map, treat existing chapters as "review" (one paragraph) and continuation as prose chapter-level planning. Hook extraction must be complete for the evidence provided.
 
 
 All output MUST be written in English.`
 All output MUST be written in English.`
       : `你是专业的网络小说架构师。从已有章节中反向推导散文密度的基础设定,同时设计续写路径。${contextBlock}${reviewFeedbackBlock}
       : `你是专业的网络小说架构师。从已有章节中反向推导散文密度的基础设定,同时设计续写路径。${contextBlock}${reviewFeedbackBlock}
@@ -965,11 +965,11 @@ ${continuationDirective}
 ## 输出契约
 ## 输出契约
 合并后的 5 段 === SECTION: === 结构:story_frame / volume_map / roles / book_rules / pending_hooks。**不要输出 rhythm_principles 或 current_state 两个 section**——节奏原则合并进 volume_map 尾段,角色初始状态合并进 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行;环境/时代锚(只有年代文 / 历史同人 / 都市重生等真实年份题材需要)织进 story_frame.世界观底色,其他题材直接省略。
 合并后的 5 段 === SECTION: === 结构:story_frame / volume_map / roles / book_rules / pending_hooks。**不要输出 rhythm_principles 或 current_state 两个 section**——节奏原则合并进 volume_map 尾段,角色初始状态合并进 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行;环境/时代锚(只有年代文 / 历史同人 / 都市重生等真实年份题材需要)织进 story_frame.世界观底色,其他题材直接省略。
 
 
-所有 prose 必须从正文中推导,不得臆造。volume_map 中,已有章节作为"回顾段"(一段散文),续写部分写到章级 prose。伏笔识别要完整(所有悬而未决的线索)。`;
+所有 prose 必须从资料包中推导,不得臆造。若资料包声明为压缩包,把章节目录和正文摘录当作基础设定证据;完整章节会在后续回放阶段逐章进入 truth files。volume_map 中,已有章节作为"回顾段"(一段散文),续写部分写到章级 prose。伏笔识别以资料包提供的证据为准,尽量完整。`;
 
 
     const userMessage = resolvedLanguage === "en"
     const userMessage = resolvedLanguage === "en"
       ? `Generate the complete foundation for an imported ${gp.name} novel titled "${book.title}". Write everything in English.\n\n${chaptersText}`
       ? `Generate the complete foundation for an imported ${gp.name} novel titled "${book.title}". Write everything in English.\n\n${chaptersText}`
-      : `以下是《${book.title}》的全部已有正文,请从中反向推导完整基础设定:\n\n${chaptersText}`;
+      : `以下是《${book.title}》的已有正文资料包,请从中反向推导完整基础设定:\n\n${chaptersText}`;
 
 
     const response = await this.chat([
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       { role: "system", content: systemPrompt },

+ 46 - 0
packages/core/src/agents/state-validator.ts

@@ -10,6 +10,12 @@ export interface ValidationResult {
   readonly passed: boolean;
   readonly passed: boolean;
 }
 }
 
 
+export interface StateValidationAuthorityContext {
+  readonly storyFrame?: string;
+  readonly bookRules?: string;
+  readonly chapterSummaries?: string;
+}
+
 /**
 /**
  * Validates Settler output by comparing old and new truth files via LLM.
  * Validates Settler output by comparing old and new truth files via LLM.
  * Catches contradictions, missing state changes, and temporal inconsistencies.
  * Catches contradictions, missing state changes, and temporal inconsistencies.
@@ -31,6 +37,7 @@ export class StateValidatorAgent extends BaseAgent {
     oldHooks: string,
     oldHooks: string,
     newHooks: string,
     newHooks: string,
     language: "zh" | "en" = "zh",
     language: "zh" | "en" = "zh",
+    authorityContext?: StateValidationAuthorityContext,
   ): Promise<ValidationResult> {
   ): Promise<ValidationResult> {
     const stateDiff = this.computeDiff(oldState, newState, "State Card");
     const stateDiff = this.computeDiff(oldState, newState, "State Card");
     const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
     const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
@@ -53,6 +60,7 @@ Given the chapter text and the CHANGES made to truth files (state card + hooks p
 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
+6. Cross-truth key-setting conflict — numbered rules, named laws, ranks, identities, locations, or relationship labels in the new truth files contradict the chapter text or the authority context
 
 
 Output format (simple, NOT JSON):
 Output format (simple, NOT JSON):
 - First line: exactly PASS or FAIL (nothing else on this line)
 - First line: exactly PASS or FAIL (nothing else on this line)
@@ -76,8 +84,12 @@ IMPORTANT: Output FAIL ONLY for hard contradictions — facts that directly conf
 - Hook management differences that don't contradict text
 - Hook management differences that don't contradict text
 These should be warnings with PASS, not FAIL.`;
 These should be warnings with PASS, not FAIL.`;
 
 
+    const authorityBlock = this.buildAuthorityContextBlock(authorityContext);
+
     const userPrompt = `Chapter ${chapterNumber} validation:
     const userPrompt = `Chapter ${chapterNumber} validation:
 
 
+${authorityBlock}
+
 ## State Card Changes
 ## State Card Changes
 ${stateDiff || "(no changes)"}
 ${stateDiff || "(no changes)"}
 
 
@@ -120,6 +132,40 @@ ${chapterContent.slice(0, 6000)}`;
     return parts.join("\n");
     return parts.join("\n");
   }
   }
 
 
+  private buildAuthorityContextBlock(authorityContext?: StateValidationAuthorityContext): string {
+    if (!authorityContext) return "## Authority / Cross-Truth Context\n(no authority context provided)";
+
+    const storyFrame = this.truncateHead(authorityContext.storyFrame ?? "", 3500);
+    const bookRules = this.truncateHead(authorityContext.bookRules ?? "", 2000);
+    const chapterSummaries = this.truncateTail(authorityContext.chapterSummaries ?? "", 3500);
+
+    return [
+      "## Authority / Cross-Truth Context",
+      "Authority priority: current chapter text > runtime truth files/current summaries > story_frame/book_rules > legacy story_bible intro or marketing-style prose. If the current chapter establishes a numbered/name mapping, new truth files must follow that mapping instead of preserving an older intro-only version.",
+      "",
+      "### story_frame / legacy story_bible excerpt",
+      storyFrame || "(empty)",
+      "",
+      "### book_rules excerpt",
+      bookRules || "(empty)",
+      "",
+      "### recent chapter_summaries excerpt",
+      chapterSummaries || "(empty)",
+    ].join("\n");
+  }
+
+  private truncateHead(text: string, maxChars: number): string {
+    const trimmed = text.trim();
+    if (trimmed.length <= maxChars) return trimmed;
+    return `${trimmed.slice(0, maxChars).trimEnd()}\n\n[...truncated...]`;
+  }
+
+  private truncateTail(text: string, maxChars: number): string {
+    const trimmed = text.trim();
+    if (trimmed.length <= maxChars) return trimmed;
+    return `[...truncated...]\n\n${trimmed.slice(-maxChars).trimStart()}`;
+  }
+
   private parseResult(content: string): ValidationResult {
   private parseResult(content: string): ValidationResult {
     const trimmed = content.trim();
     const trimmed = content.trim();
     if (!trimmed) {
     if (!trimmed) {

+ 6 - 1
packages/core/src/index.ts

@@ -1,7 +1,7 @@
 // Models
 // Models
 export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
-export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
+export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type FoundationConfig, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, FoundationConfigSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules, tryParseBookRulesFrontmatter } from "./models/book-rules.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules, tryParseBookRulesFrontmatter } from "./models/book-rules.js";
@@ -78,6 +78,11 @@ export {
   gatherPlanningMaterials,
   gatherPlanningMaterials,
   type PlanningMaterials,
   type PlanningMaterials,
 } from "./utils/planning-materials.js";
 } from "./utils/planning-materials.js";
+export {
+  buildProxyFetchInit,
+  fetchWithProxy,
+  resolveProxyUrl,
+} from "./utils/proxy-fetch.js";
 export { assertSafeBookId, deriveBookIdFromTitle, isSafeBookId } from "./utils/book-id.js";
 export { assertSafeBookId, deriveBookIdFromTitle, isSafeBookId } from "./utils/book-id.js";
 export { safeChildPath } from "./utils/path-safety.js";
 export { safeChildPath } from "./utils/path-safety.js";
 export {
 export {

+ 86 - 11
packages/core/src/llm/provider.ts

@@ -17,6 +17,7 @@ import type {
 import { resolveServicePreset } from "./service-presets.js";
 import { resolveServicePreset } from "./service-presets.js";
 import { getEndpoint } from "./providers/index.js";
 import { getEndpoint } from "./providers/index.js";
 import { lookupModel } from "./providers/lookup.js";
 import { lookupModel } from "./providers/lookup.js";
+import { fetchWithProxy } from "../utils/proxy-fetch.js";
 
 
 
 
 // === Streaming Monitor Types ===
 // === Streaming Monitor Types ===
@@ -32,6 +33,7 @@ export type OnStreamProgress = (progress: StreamProgress) => void;
 
 
 const INKOS_USER_AGENT = "InkOS/1.3.5";
 const INKOS_USER_AGENT = "InkOS/1.3.5";
 const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
 const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
+const TRANSIENT_LLM_RETRIES = 2;
 
 
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
@@ -99,6 +101,7 @@ export interface LLMClient {
   readonly configSource?: LLMConfig["configSource"];
   readonly configSource?: LLMConfig["configSource"];
   readonly apiFormat: "chat" | "responses";
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly stream: boolean;
+  readonly proxyUrl?: string;
   readonly _piModel?: PiModel<PiApi>;
   readonly _piModel?: PiModel<PiApi>;
   readonly _apiKey?: string;
   readonly _apiKey?: string;
   readonly defaults: {
   readonly defaults: {
@@ -208,6 +211,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
     configSource: config.configSource,
     configSource: config.configSource,
     apiFormat,
     apiFormat,
     stream,
     stream,
+    proxyUrl: config.proxyUrl,
     _piModel: piModel,
     _piModel: piModel,
     _apiKey: config.apiKey,
     _apiKey: config.apiKey,
     defaults,
     defaults,
@@ -363,7 +367,17 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
       `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
       `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
     );
     );
   }
   }
-  if (msg.includes("Connection error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
+  if (
+    msg.includes("Connection error")
+    || msg.includes("ECONNREFUSED")
+    || msg.includes("ENOTFOUND")
+    || msg.includes("fetch failed")
+    || msg.includes("terminated")
+    || msg.includes("UND_ERR_SOCKET")
+    || msg.includes("ECONNRESET")
+    || msg.includes("ETIMEDOUT")
+    || msg.includes("EPIPE")
+  ) {
     return new Error(
     return new Error(
       `无法连接到 API 服务。可能原因:\n` +
       `无法连接到 API 服务。可能原因:\n` +
       `  1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
       `  1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
@@ -396,6 +410,61 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
   return error instanceof Error ? error : new Error(msg);
   return error instanceof Error ? error : new Error(msg);
 }
 }
 
 
+function collectErrorText(error: unknown, depth = 0): string {
+  if (depth > 4 || error === null || error === undefined) return "";
+  const parts = [String(error)];
+  if (error instanceof Error) {
+    parts.push(error.name, error.message);
+    const cause = (error as Error & { cause?: unknown }).cause;
+    if (cause) parts.push(collectErrorText(cause, depth + 1));
+  } else if (typeof error === "object") {
+    const err = error as { code?: unknown; cause?: unknown; message?: unknown; name?: unknown };
+    if (err.name) parts.push(String(err.name));
+    if (err.message) parts.push(String(err.message));
+    if (err.code) parts.push(String(err.code));
+    if (err.cause) parts.push(collectErrorText(err.cause, depth + 1));
+  }
+  return parts.join("\n");
+}
+
+function isTransientLLMTransportError(error: unknown): boolean {
+  const text = collectErrorText(error);
+  return [
+    "terminated",
+    "UND_ERR_SOCKET",
+    "ECONNRESET",
+    "ETIMEDOUT",
+    "EPIPE",
+    "socket hang up",
+    "other side closed",
+    "network socket disconnected",
+  ].some((needle) => text.includes(needle));
+}
+
+async function withTransientLLMRetry<T>(
+  run: () => Promise<T>,
+  options?: { readonly enabled?: boolean },
+): Promise<T> {
+  const enabled = options?.enabled ?? true;
+  let lastError: unknown;
+  for (let attempt = 0; attempt <= TRANSIENT_LLM_RETRIES; attempt++) {
+    try {
+      return await run();
+    } catch (error) {
+      lastError = error;
+      if (
+        !enabled
+        || attempt >= TRANSIENT_LLM_RETRIES
+        || error instanceof PartialResponseError
+        || !isTransientLLMTransportError(error)
+      ) {
+        throw error;
+      }
+    }
+  }
+  throw lastError;
+}
+
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
   return client.configSource === "studio"
   return client.configSource === "studio"
     && client.service === "custom"
     && client.service === "custom"
@@ -589,7 +658,7 @@ async function chatCompletionViaCustomAnthropicCompatible(
   const system = joinSystemPrompt(messages);
   const system = joinSystemPrompt(messages);
   if (system) payload.system = system;
   if (system) payload.system = system;
 
 
-  const response = await fetch(`${baseUrl.replace(/\/$/, "")}/messages`, {
+  const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/messages`, {
     method: "POST",
     method: "POST",
     headers: {
     headers: {
       "User-Agent": INKOS_USER_AGENT,
       "User-Agent": INKOS_USER_AGENT,
@@ -600,7 +669,7 @@ async function chatCompletionViaCustomAnthropicCompatible(
       ...(client._piModel?.headers ?? {}),
       ...(client._piModel?.headers ?? {}),
     },
     },
     body: JSON.stringify(payload),
     body: JSON.stringify(payload),
-  });
+  }, client.proxyUrl);
 
 
   if (!response.ok) {
   if (!response.ok) {
     throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
     throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
@@ -699,11 +768,11 @@ async function chatCompletionViaCustomOpenAICompatible(
     const instructions = joinSystemPrompt(messages);
     const instructions = joinSystemPrompt(messages);
     if (instructions) payload.instructions = instructions;
     if (instructions) payload.instructions = instructions;
 
 
-    const response = await fetch(`${baseUrl.replace(/\/$/, "")}/responses`, {
+    const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/responses`, {
       method: "POST",
       method: "POST",
       headers,
       headers,
       body: JSON.stringify(payload),
       body: JSON.stringify(payload),
-    });
+    }, client.proxyUrl);
     if (!response.ok) {
     if (!response.ok) {
       throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
       throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
     }
     }
@@ -785,11 +854,11 @@ async function chatCompletionViaCustomOpenAICompatible(
     payload.stream_options = { include_usage: true };
     payload.stream_options = { include_usage: true };
   }
   }
 
 
-  const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
+  const response = await fetchWithProxy(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
     method: "POST",
     method: "POST",
     headers,
     headers,
     body: JSON.stringify(payload),
     body: JSON.stringify(payload),
-  });
+  }, client.proxyUrl);
   if (!response.ok) {
   if (!response.ok) {
     const detail = await readErrorResponse(response);
     const detail = await readErrorResponse(response);
     if (allowSystemRoleFallback && hasSystemMessages(messages) && isSystemRoleUnsupportedErrorText(detail)) {
     if (allowSystemRoleFallback && hasSystemMessages(messages) && isSystemRoleUnsupportedErrorText(detail)) {
@@ -901,10 +970,16 @@ export async function chatCompletion(
   const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
   const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
 
 
   try {
   try {
-    if (shouldUseNativeCustomTransport(client)) {
-      return await chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
-    }
-    return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
+    return await withTransientLLMRetry(
+      async () => {
+        if (shouldUseNativeCustomTransport(client)) {
+          return chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
+        }
+        return chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
+      },
+      // Retrying after UI text deltas have been emitted can duplicate visible text.
+      { enabled: !onTextDelta },
+    );
   } catch (error) {
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {
     if (error instanceof PartialResponseError) {

+ 3 - 1
packages/core/src/llm/providers/probe.ts

@@ -1,3 +1,5 @@
+import { fetchWithProxy } from "../../utils/proxy-fetch.js";
+
 /**
 /**
  * 通用 OpenAI 兼容 /models 探针。
  * 通用 OpenAI 兼容 /models 探针。
  * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
  * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
@@ -17,7 +19,7 @@ export async function probeModelsFromUpstream(
   if (!baseUrl || !apiKey) return [];
   if (!baseUrl || !apiKey) return [];
   try {
   try {
     const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
     const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
-    const res = await fetch(modelsUrl, {
+    const res = await fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(timeoutMs),
       signal: AbortSignal.timeout(timeoutMs),
     });
     });

+ 7 - 4
packages/core/src/llm/providers/verify.ts

@@ -1,5 +1,6 @@
 import { getEndpoint } from "./index.js";
 import { getEndpoint } from "./index.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
+import { fetchWithProxy } from "../../utils/proxy-fetch.js";
 
 
 export interface VerifyResult {
 export interface VerifyResult {
   readonly recommendedTransport?: {
   readonly recommendedTransport?: {
@@ -26,6 +27,7 @@ async function probe(
   service: string,
   service: string,
   apiKey: string,
   apiKey: string,
   baseUrl?: string,
   baseUrl?: string,
+  proxyUrl?: string,
 ): Promise<VerifyResult["probe"]> {
 ): Promise<VerifyResult["probe"]> {
   const provider = getEndpoint(service);
   const provider = getEndpoint(service);
   const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
   const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
@@ -34,10 +36,10 @@ async function probe(
   }
   }
   try {
   try {
     const url = probeBaseUrl.replace(/\/$/, "") + "/models";
     const url = probeBaseUrl.replace(/\/$/, "") + "/models";
-    const res = await fetch(url, {
+    const res = await fetchWithProxy(url, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) {
     if (!res.ok) {
       return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
       return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
     }
     }
@@ -61,9 +63,9 @@ async function probe(
 export async function verifyService(
 export async function verifyService(
   service: string,
   service: string,
   apiKey: string,
   apiKey: string,
-  opts?: { checkModel?: string; baseUrl?: string },
+  opts?: { checkModel?: string; baseUrl?: string; proxyUrl?: string },
 ): Promise<VerifyResult> {
 ): Promise<VerifyResult> {
-  const probeResult = await probe(service, apiKey, opts?.baseUrl);
+  const probeResult = await probe(service, apiKey, opts?.baseUrl, opts?.proxyUrl);
 
 
   const provider = getEndpoint(service);
   const provider = getEndpoint(service);
   const recommendedTransport = provider?.transportDefaults;
   const recommendedTransport = provider?.transportDefaults;
@@ -82,6 +84,7 @@ export async function verifyService(
       model: checkModel,
       model: checkModel,
       apiKey,
       apiKey,
       baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
       baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
+      proxyUrl: opts?.proxyUrl,
       configSource: "studio",
       configSource: "studio",
       stream: false,
       stream: false,
     }));
     }));

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

@@ -20,6 +20,7 @@ export const LLMConfigSchema = z.object({
   baseUrl: z.string().url(),
   baseUrl: z.string().url(),
   apiKey: z.string().default(""),
   apiKey: z.string().default(""),
   model: z.string().min(1),
   model: z.string().min(1),
+  proxyUrl: z.string().url().optional(),
   temperature: z.number().min(0).max(2).default(0.7),
   temperature: z.number().min(0).max(2).default(0.7),
   thinkingBudget: z.number().int().min(0).default(0),
   thinkingBudget: z.number().int().min(0).default(0),
   extra: z.record(z.unknown()).optional(),
   extra: z.record(z.unknown()).optional(),
@@ -76,6 +77,12 @@ export const QualityGatesSchema = z.object({
 
 
 export type QualityGates = z.infer<typeof QualityGatesSchema>;
 export type QualityGates = z.infer<typeof QualityGatesSchema>;
 
 
+export const FoundationConfigSchema = z.object({
+  reviewRetries: z.number().int().min(0).max(10).default(2),
+});
+
+export type FoundationConfig = z.infer<typeof FoundationConfigSchema>;
+
 export const AgentLLMOverrideSchema = z.object({
 export const AgentLLMOverrideSchema = z.object({
   model: z.string().min(1),
   model: z.string().min(1),
   provider: z.enum(["anthropic", "openai", "custom"]).optional(),
   provider: z.enum(["anthropic", "openai", "custom"]).optional(),
@@ -98,6 +105,9 @@ export const ProjectConfigSchema = z.object({
   llm: LLMConfigSchema,
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),
   detection: DetectionConfigSchema.optional(),
+  foundation: FoundationConfigSchema.default({
+    reviewRetries: 2,
+  }),
   modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
   modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
   inputGovernanceMode: InputGovernanceModeSchema.default("v2"),
   inputGovernanceMode: InputGovernanceModeSchema.default("v2"),
   daemon: z.object({
   daemon: z.object({

+ 3 - 1
packages/core/src/pipeline/chapter-truth-validation.ts

@@ -1,5 +1,5 @@
 import type { AuditIssue, AuditResult } from "../agents/continuity.js";
 import type { AuditIssue, AuditResult } from "../agents/continuity.js";
-import type { ValidationResult, StateValidatorAgent } from "../agents/state-validator.js";
+import type { StateValidationAuthorityContext, ValidationResult, StateValidatorAgent } from "../agents/state-validator.js";
 import type { WriteChapterOutput, WriterAgent } from "../agents/writer.js";
 import type { WriteChapterOutput, WriterAgent } from "../agents/writer.js";
 import type { BookConfig } from "../models/book.js";
 import type { BookConfig } from "../models/book.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
@@ -25,6 +25,7 @@ export async function validateChapterTruthPersistence(params: {
     readonly oldHooks: string;
     readonly oldHooks: string;
     readonly oldLedger: string;
     readonly oldLedger: string;
   };
   };
+  readonly authorityContext?: StateValidationAuthorityContext;
   readonly reducedControlInput?: {
   readonly reducedControlInput?: {
     chapterIntent: string;
     chapterIntent: string;
     contextPackage: ContextPackage;
     contextPackage: ContextPackage;
@@ -55,6 +56,7 @@ export async function validateChapterTruthPersistence(params: {
       params.previousTruth.oldHooks,
       params.previousTruth.oldHooks,
       persistenceOutput.updatedHooks,
       persistenceOutput.updatedHooks,
       params.language,
       params.language,
+      params.authorityContext,
     );
     );
   } catch (error) {
   } catch (error) {
     params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);
     params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);

+ 271 - 29
packages/core/src/pipeline/runner.ts

@@ -65,11 +65,165 @@ function isSequenceLevelCategory(category: string): boolean {
   return SEQUENCE_LEVEL_CATEGORIES.has(category);
   return SEQUENCE_LEVEL_CATEGORIES.has(category);
 }
 }
 
 
+interface ImportFoundationSourceOptions {
+  readonly maxFullTextChars?: number;
+  readonly chapterExcerptChars?: number;
+  readonly titleCatalogChars?: number;
+  readonly edgeChapterCount?: number;
+  readonly middleAnchorCount?: number;
+}
+
+const DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS = 80_000;
+const DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS = 6_000;
+const DEFAULT_IMPORT_TITLE_CATALOG_CHARS = 24_000;
+const DEFAULT_IMPORT_EDGE_CHAPTER_COUNT = 4;
+const DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT = 8;
+
+function formatImportedChapter(
+  chapter: { readonly title: string; readonly content: string },
+  index: number,
+  language: LengthLanguage,
+  content = chapter.content,
+): string {
+  return language === "en"
+    ? `Chapter ${index + 1}: ${chapter.title}\n\n${content}`
+    : `第${index + 1}章 ${chapter.title}\n\n${content}`;
+}
+
+function estimateImportFullTextLength(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+): number {
+  return chapters.reduce((total, chapter) => total + chapter.title.length + chapter.content.length + 24, 0);
+}
+
+function excerptHeadTail(text: string, maxChars: number, language: LengthLanguage): string {
+  const clean = text.trim();
+  if (clean.length <= maxChars) return clean;
+  const headChars = Math.max(200, Math.floor(maxChars * 0.6));
+  const tailChars = Math.max(200, maxChars - headChars);
+  const omitted = clean.length - headChars - tailChars;
+  const marker = language === "en"
+    ? `\n\n[... ${omitted} chars omitted for import-context budget ...]\n\n`
+    : `\n\n【中间省略 ${omitted} 字,用于控制导入上下文预算】\n\n`;
+  return `${clean.slice(0, headChars).trimEnd()}${marker}${clean.slice(-tailChars).trimStart()}`;
+}
+
+function pickImportAnchorIndexes(
+  chapterCount: number,
+  edgeChapterCount: number,
+  middleAnchorCount: number,
+): ReadonlyArray<number> {
+  const selected = new Set<number>();
+  for (let i = 0; i < Math.min(edgeChapterCount, chapterCount); i++) selected.add(i);
+  for (let i = Math.max(0, chapterCount - edgeChapterCount); i < chapterCount; i++) selected.add(i);
+
+  const middleStart = Math.min(edgeChapterCount, chapterCount);
+  const middleEnd = Math.max(middleStart, chapterCount - edgeChapterCount);
+  const middleSize = middleEnd - middleStart;
+  const anchors = Math.min(middleAnchorCount, middleSize);
+  for (let i = 0; i < anchors; i++) {
+    const offset = Math.floor(((i + 1) * middleSize) / (anchors + 1));
+    selected.add(Math.min(chapterCount - 1, middleStart + offset));
+  }
+
+  return [...selected].sort((a, b) => a - b);
+}
+
+function buildTitleCatalog(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+  language: LengthLanguage,
+  maxChars: number,
+): string {
+  const lines = chapters.map((chapter, index) =>
+    language === "en"
+      ? `- Chapter ${index + 1}: ${chapter.title} (${chapter.content.length} chars)`
+      : `- 第${index + 1}章:${chapter.title}(${chapter.content.length}字)`,
+  );
+  const joined = lines.join("\n");
+  if (joined.length <= maxChars) return joined;
+
+  const headBudget = Math.floor(maxChars * 0.55);
+  const tailBudget = maxChars - headBudget;
+  const head: string[] = [];
+  const tail: string[] = [];
+  let headChars = 0;
+  let tailChars = 0;
+  for (const line of lines) {
+    if (headChars + line.length + 1 > headBudget) break;
+    head.push(line);
+    headChars += line.length + 1;
+  }
+  for (let i = lines.length - 1; i >= 0; i--) {
+    const line = lines[i]!;
+    if (tailChars + line.length + 1 > tailBudget) break;
+    tail.unshift(line);
+    tailChars += line.length + 1;
+  }
+  const omitted = lines.length - head.length - tail.length;
+  const marker = language === "en"
+    ? `- ... ${omitted} chapter titles omitted ...`
+    : `- ……中间 ${omitted} 个章节标题省略……`;
+  return [...head, marker, ...tail].join("\n");
+}
+
+export function buildImportFoundationSource(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+  language: LengthLanguage,
+  options: ImportFoundationSourceOptions = {},
+): string {
+  const maxFullTextChars = options.maxFullTextChars ?? DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS;
+  const chapterExcerptChars = options.chapterExcerptChars ?? DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS;
+  const titleCatalogChars = options.titleCatalogChars ?? DEFAULT_IMPORT_TITLE_CATALOG_CHARS;
+  const edgeChapterCount = options.edgeChapterCount ?? DEFAULT_IMPORT_EDGE_CHAPTER_COUNT;
+  const middleAnchorCount = options.middleAnchorCount ?? DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT;
+
+  if (estimateImportFullTextLength(chapters) <= maxFullTextChars) {
+    return chapters.map((chapter, index) => formatImportedChapter(chapter, index, language)).join("\n\n---\n\n");
+  }
+
+  const anchorIndexes = pickImportAnchorIndexes(chapters.length, edgeChapterCount, middleAnchorCount);
+  const header = language === "en"
+    ? [
+        "## Import foundation source package",
+        "",
+        `The imported book has ${chapters.length} chapters. To avoid overflowing the LLM context, this package keeps the opening chapters, ending/continuation point, selected middle anchors, and a capped title catalog. Full chapters will still be replayed sequentially after foundation generation to rebuild truth files.`,
+      ].join("\n")
+    : [
+        "## 导入基础设定压缩资料包",
+        "",
+        `本次导入共 ${chapters.length} 章。为避免超出 LLM 上下文,这里保留开篇、结尾续写点、少量中段锚点和标题目录;完整章节将在后续顺序回放中逐章分析并沉淀 truth files。`,
+      ].join("\n");
+  const catalogTitle = language === "en" ? "## Capped chapter title catalog" : "## 章节标题目录(截断)";
+  const anchorsTitle = language === "en" ? "## Source excerpts for architecture" : "## 用于反推基础设定的正文摘录";
+  const anchorText = anchorIndexes
+    .map((index) => {
+      const chapter = chapters[index]!;
+      return formatImportedChapter(
+        chapter,
+        index,
+        language,
+        excerptHeadTail(chapter.content, chapterExcerptChars, language),
+      );
+    })
+    .join("\n\n---\n\n");
+
+  return [
+    header,
+    "",
+    catalogTitle,
+    buildTitleCatalog(chapters, language, titleCatalogChars),
+    "",
+    anchorsTitle,
+    anchorText,
+  ].join("\n");
+}
+
 export interface PipelineConfig {
 export interface PipelineConfig {
   readonly client: LLMClient;
   readonly client: LLMClient;
   readonly model: string;
   readonly model: string;
   readonly projectRoot: string;
   readonly projectRoot: string;
   readonly defaultLLMConfig?: LLMConfig;
   readonly defaultLLMConfig?: LLMConfig;
+  readonly foundationReviewRetries?: number;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly externalContext?: string;
   readonly externalContext?: string;
@@ -269,7 +423,7 @@ export class PipelineRunner {
     readonly stageLanguage: LengthLanguage;
     readonly stageLanguage: LengthLanguage;
     readonly maxRetries?: number;
     readonly maxRetries?: number;
   }): Promise<ArchitectOutput> {
   }): Promise<ArchitectOutput> {
-    const maxRetries = params.maxRetries ?? 2;
+    const maxRetries = params.maxRetries ?? this.config.foundationReviewRetries ?? 2;
     let foundation = await params.generate();
     let foundation = await params.generate();
 
 
     for (let attempt = 0; attempt < maxRetries; attempt++) {
     for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -1581,10 +1735,13 @@ export class PipelineRunner {
     // 4.1 Validate settler output before writing
     // 4.1 Validate settler output before writing
     this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
     this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
     const storyDir = join(bookDir, "story");
     const storyDir = join(bookDir, "story");
-    const [oldState, oldHooks, oldLedger] = await Promise.all([
+    const [oldState, oldHooks, oldLedger, authorityStoryFrame, authorityBookRules, authorityChapterSummaries] = await Promise.all([
       readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
+      readStoryFrame(bookDir).catch(() => ""),
+      readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
     ]);
     ]);
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
     const truthValidation = await validateChapterTruthPersistence({
     const truthValidation = await validateChapterTruthPersistence({
@@ -1602,6 +1759,11 @@ export class PipelineRunner {
         oldHooks,
         oldHooks,
         oldLedger,
         oldLedger,
       },
       },
+      authorityContext: {
+        storyFrame: authorityStoryFrame,
+        bookRules: authorityBookRules,
+        chapterSummaries: authorityChapterSummaries,
+      },
       reducedControlInput,
       reducedControlInput,
       language: pipelineLang,
       language: pipelineLang,
       logWarn: (message) => this.logWarn(pipelineLang, message),
       logWarn: (message) => this.logWarn(pipelineLang, message),
@@ -2001,8 +2163,9 @@ export class PipelineRunner {
    * Also saves the statistical style_profile.json.
    * Also saves the statistical style_profile.json.
    */
    */
   async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
   async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
-    if (referenceText.length < 500) {
-      throw new Error(`Reference text too short (${referenceText.length} chars, minimum 500). Provide at least 2000 chars for reliable style extraction.`);
+    const sample = referenceText.trim();
+    if (!sample) {
+      throw new Error("Reference text is required for style extraction.");
     }
     }
 
 
     const { analyzeStyle } = await import("../agents/style-analyzer.js");
     const { analyzeStyle } = await import("../agents/style-analyzer.js");
@@ -2011,14 +2174,28 @@ export class PipelineRunner {
     await mkdir(storyDir, { recursive: true });
     await mkdir(storyDir, { recursive: true });
 
 
     // Statistical fingerprint
     // Statistical fingerprint
-    const profile = analyzeStyle(referenceText, sourceName);
+    const profile = analyzeStyle(sample, sourceName);
     await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
     await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
 
 
-    // LLM qualitative extraction
-    const response = await chatCompletion(this.config.client, this.config.model, [
-      {
-        role: "system",
-        content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
+    const book = await this.state.loadBookConfig(bookId);
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    const lang = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
+
+    let qualitativeGuide: string;
+    if (sample.length < 500) {
+      qualitativeGuide = this.buildDeterministicStyleGuide(profile, {
+        language: lang,
+        reason: lang === "en"
+          ? `The sample is short (${sample.length} chars), so this guide uses the statistical fingerprint instead of LLM qualitative extraction.`
+          : `样本文本较短(${sample.length}字),本次先使用统计指纹生成文风指南,不强行调用 LLM 做定性拆解。`,
+      });
+    } else {
+      try {
+        // LLM qualitative extraction
+        const response = await chatCompletion(this.config.client, this.config.model, [
+          {
+            role: "system",
+            content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
 
 
 输出格式(Markdown):
 输出格式(Markdown):
 ## 叙事声音与语气
 ## 叙事声音与语气
@@ -2046,22 +2223,91 @@ export class PipelineRunner {
 (任何值得模仿的个人写作习惯)
 (任何值得模仿的个人写作习惯)
 
 
 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
-      },
-      {
-        role: "user",
-        content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
-      },
-    ], { temperature: 0.3 });
+          },
+          {
+            role: "user",
+            content: `分析以下参考文本的写作风格:\n\n${sample.slice(0, 20000)}`,
+          },
+        ], { temperature: 0.3 });
+        qualitativeGuide = response.content.trim()
+          ? response.content
+          : this.buildDeterministicStyleGuide(profile, {
+              language: lang,
+              reason: lang === "en"
+                ? "The LLM returned empty style analysis; using the statistical fingerprint fallback."
+                : "LLM 未返回有效文风分析,本次使用统计指纹兜底生成文风指南。",
+            });
+      } catch (error) {
+        qualitativeGuide = this.buildDeterministicStyleGuide(profile, {
+          language: lang,
+          reason: lang === "en"
+            ? `LLM qualitative extraction failed: ${error instanceof Error ? error.message : String(error)}. Using the statistical fingerprint fallback.`
+            : `LLM 定性拆解失败:${error instanceof Error ? error.message : String(error)}。本次使用统计指纹兜底生成文风指南。`,
+        });
+      }
+    }
 
 
-    const book = await this.state.loadBookConfig(bookId);
-    const { profile: gp } = await this.loadGenreProfile(book.genre);
-    const lang = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
     const craftMethodology = buildWritingMethodologySection(lang);
     const craftMethodology = buildWritingMethodologySection(lang);
-    const fullStyleGuide = `${response.content}\n\n${craftMethodology}`;
+    const fullStyleGuide = `${qualitativeGuide}\n\n${craftMethodology}`;
     await writeFile(join(storyDir, "style_guide.md"), fullStyleGuide, "utf-8");
     await writeFile(join(storyDir, "style_guide.md"), fullStyleGuide, "utf-8");
     return fullStyleGuide;
     return fullStyleGuide;
   }
   }
 
 
+  private buildDeterministicStyleGuide(
+    profile: {
+      readonly avgSentenceLength: number;
+      readonly sentenceLengthStdDev: number;
+      readonly avgParagraphLength: number;
+      readonly vocabularyDiversity: number;
+      readonly topPatterns: ReadonlyArray<string>;
+      readonly rhetoricalFeatures: ReadonlyArray<string>;
+      readonly sourceName?: string;
+    },
+    options: { readonly language: "zh" | "en"; readonly reason: string },
+  ): string {
+    if (options.language === "en") {
+      return [
+        "# Style Guide",
+        "",
+        `> ${options.reason}`,
+        "",
+        "## Statistical Fingerprint",
+        `- Source: ${profile.sourceName ?? "unknown"}`,
+        `- Average sentence length: ${profile.avgSentenceLength}`,
+        `- Sentence length variance: ${profile.sentenceLengthStdDev}`,
+        `- Average paragraph length: ${profile.avgParagraphLength}`,
+        `- Vocabulary diversity: ${Math.round(profile.vocabularyDiversity * 100)}%`,
+        profile.topPatterns.length > 0 ? `- Repeated openings: ${profile.topPatterns.join(", ")}` : "- Repeated openings: none obvious in this sample",
+        profile.rhetoricalFeatures.length > 0 ? `- Rhetorical features: ${profile.rhetoricalFeatures.join(", ")}` : "- Rhetorical features: none obvious in this sample",
+        "",
+        "## How To Use",
+        "- Treat this as a lightweight style fingerprint, not a full imitation bible.",
+        "- Keep sentence and paragraph rhythm close to the sample when drafting.",
+        "- If this guide feels too thin, import a longer excerpt later; the file will be replaced.",
+      ].join("\n");
+    }
+
+    return [
+      "# 文风指南",
+      "",
+      `> ${options.reason}`,
+      "",
+      "## 统计风格指纹",
+      `- 来源:${profile.sourceName ?? "unknown"}`,
+      `- 平均句长:${profile.avgSentenceLength}`,
+      `- 句长波动:${profile.sentenceLengthStdDev}`,
+      `- 平均段落长度:${profile.avgParagraphLength}`,
+      `- 词汇多样性:${Math.round(profile.vocabularyDiversity * 100)}%`,
+      profile.topPatterns.length > 0 ? `- 高频句首/模式:${profile.topPatterns.join("、")}` : "- 高频句首/模式:样本内不明显",
+      profile.rhetoricalFeatures.length > 0 ? `- 修辞特征:${profile.rhetoricalFeatures.join("、")}` : "- 修辞特征:样本内不明显",
+      "",
+      "## 使用方式",
+      "- 这是一份轻量文风指纹,不是完整仿写圣经。",
+      "- 后续写作优先参考句长、段落长度、节奏波动和可见修辞。",
+      "- 如果想得到更稳定的定性拆解,后续可以导入更长片段覆盖本文件。",
+    ].join("\n");
+  }
+
   /**
   /**
    * Import canon from parent book for spinoff writing.
    * Import canon from parent book for spinoff writing.
    * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
    * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
@@ -2260,23 +2506,19 @@ ${matrix}`,
           zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
           zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
           en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
           en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
         }));
         }));
-        const allText = input.chapters.map((c, i) =>
-          resolvedLanguage === "en"
-            ? `Chapter ${i + 1}: ${c.title}\n\n${c.content}`
-            : `第${i + 1}章 ${c.title}\n\n${c.content}`,
-        ).join("\n\n---\n\n");
+        const foundationSource = buildImportFoundationSource(input.chapters, resolvedLanguage);
 
 
         const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
         const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
         const isSeries = input.importMode === "series";
         const isSeries = input.importMode === "series";
         const foundation = isSeries
         const foundation = isSeries
           ? await this.generateAndReviewFoundation({
           ? await this.generateAndReviewFoundation({
-              generate: (reviewFeedback) => architect.generateFoundationFromImport(book, allText, undefined, reviewFeedback, { importMode: "series" }),
+              generate: (reviewFeedback) => architect.generateFoundationFromImport(book, foundationSource, undefined, reviewFeedback, { importMode: "series" }),
               reviewer: new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", input.bookId)),
               reviewer: new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", input.bookId)),
               mode: "series",
               mode: "series",
               language: resolvedLanguage === "en" ? "en" : "zh",
               language: resolvedLanguage === "en" ? "en" : "zh",
               stageLanguage: resolvedLanguage,
               stageLanguage: resolvedLanguage,
             })
             })
-          : await architect.generateFoundationFromImport(book, allText);
+          : await architect.generateFoundationFromImport(book, foundationSource);
         await architect.writeFoundationFiles(
         await architect.writeFoundationFiles(
           bookDir,
           bookDir,
           foundation,
           foundation,
@@ -2288,12 +2530,12 @@ ${matrix}`,
         await this.state.snapshotState(input.bookId, 0);
         await this.state.snapshotState(input.bookId, 0);
 
 
         // Generate style guide from imported chapters
         // Generate style guide from imported chapters
-        if (allText.length >= 500) {
+        if (foundationSource.length >= 500) {
           log?.info(this.localize(resolvedLanguage, {
           log?.info(this.localize(resolvedLanguage, {
             zh: "提取原文风格指纹...",
             zh: "提取原文风格指纹...",
             en: "Extracting source style fingerprint...",
             en: "Extracting source style fingerprint...",
           }));
           }));
-          await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage);
+          await this.tryGenerateStyleGuide(input.bookId, foundationSource, book.title, resolvedLanguage);
         }
         }
 
 
         log?.info(this.localize(resolvedLanguage, {
         log?.info(this.localize(resolvedLanguage, {

+ 1 - 0
packages/core/src/utils/effective-llm-config.ts

@@ -327,6 +327,7 @@ function applyCommonEnv(
 ): void {
 ): void {
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
   if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  if (env.INKOS_LLM_PROXY_URL) llm.proxyUrl = env.INKOS_LLM_PROXY_URL;
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
   if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;

+ 44 - 0
packages/core/src/utils/proxy-fetch.ts

@@ -0,0 +1,44 @@
+import { ProxyAgent } from "undici";
+
+type ProxyEnv = Record<string, string | undefined>;
+type FetchInitWithDispatcher = RequestInit & { dispatcher?: unknown };
+
+export function resolveProxyUrl(explicitProxyUrl?: string, env: ProxyEnv = process.env): string | undefined {
+  const candidate = [
+    explicitProxyUrl,
+    env.INKOS_LLM_PROXY_URL,
+    env.HTTPS_PROXY,
+    env.https_proxy,
+    env.HTTP_PROXY,
+    env.http_proxy,
+  ].find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
+
+  if (!candidate) return undefined;
+  const parsed = new URL(candidate);
+  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+    throw new Error(`Unsupported proxy protocol: ${parsed.protocol}`);
+  }
+  return candidate;
+}
+
+export function buildProxyFetchInit(
+  init: RequestInit = {},
+  explicitProxyUrl?: string,
+  env: ProxyEnv = process.env,
+): FetchInitWithDispatcher {
+  const proxyUrl = resolveProxyUrl(explicitProxyUrl, env);
+  if (!proxyUrl) return init;
+  return {
+    ...init,
+    dispatcher: new ProxyAgent(proxyUrl),
+  };
+}
+
+export function fetchWithProxy(
+  input: Parameters<typeof fetch>[0],
+  init: RequestInit = {},
+  explicitProxyUrl?: string,
+  env: ProxyEnv = process.env,
+): ReturnType<typeof fetch> {
+  return fetch(input, buildProxyFetchInit(init, explicitProxyUrl, env));
+}

+ 76 - 6
packages/studio/src/api/server.test.ts

@@ -230,6 +230,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
     listModelsForService: listModelsForServiceMock,
     listModelsForService: listModelsForServiceMock,
     getAllEndpoints: getAllEndpointsMock,
     getAllEndpoints: getAllEndpointsMock,
     probeModelsFromUpstream: probeModelsFromUpstreamMock,
     probeModelsFromUpstream: probeModelsFromUpstreamMock,
+    fetchWithProxy: vi.fn((input: Parameters<typeof fetch>[0], init?: RequestInit) => fetch(input, init)),
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
   };
 });
 });
@@ -953,6 +954,30 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
   });
   });
 
 
+  it("returns the saved default service and model for Studio chat selection", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "google", temperature: 1 },
+          { service: "moonshot", temperature: 0.7 },
+        ],
+        service: "moonshot",
+        defaultModel: "kimi-k2.5",
+      },
+    }, 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({
+      service: "moonshot",
+      defaultModel: "kimi-k2.5",
+    });
+  });
+
   it("rejects switching Studio runtime to env config source", async () => {
   it("rejects switching Studio runtime to env config source", async () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -1744,12 +1769,30 @@ describe("createStudioServer daemon lifecycle", () => {
   });
   });
 
 
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
-    runAgentSessionMock.mockResolvedValueOnce({
-      responseText: "Completed write_next for demo-book.",
-      messages: [
-        { role: "user", content: "continue" },
-        { role: "assistant", content: "Completed write_next for demo-book." },
-      ],
+    runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
+      config.onEvent?.({
+        type: "tool_execution_start",
+        toolName: "sub_agent",
+        toolCallId: "tool-writer-1",
+        args: { agent: "writer" },
+      });
+      config.onEvent?.({
+        type: "tool_execution_end",
+        toolName: "sub_agent",
+        toolCallId: "tool-writer-1",
+        isError: false,
+        result: {
+          content: [{ type: "text", text: "Chapter written for demo-book. Word count: 1800." }],
+          details: { kind: "chapter_written", bookId: "demo-book", chapterNumber: 4 },
+        },
+      });
+      return {
+        responseText: "Completed write_next for demo-book.",
+        messages: [
+          { role: "user", content: "continue" },
+          { role: "assistant", content: "Completed write_next for demo-book." },
+        ],
+      };
     });
     });
 
 
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
@@ -1777,6 +1820,33 @@ describe("createStudioServer daemon lifecycle", () => {
     );
     );
   });
   });
 
 
+  it("rejects text-only write-next agent responses that never executed the writer tool", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "已为 demo-book 完成下一章写作。",
+      messages: [
+        { role: "user", content: "继续" },
+        { role: "assistant", content: "已为 demo-book 完成下一章写作。" },
+      ],
+    });
+
+    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: "继续", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(502);
+    await expect(response.json()).resolves.toMatchObject({
+      error: {
+        code: "AGENT_ACTION_NOT_EXECUTED",
+      },
+      response: expect.stringContaining("没有实际调用写作工具"),
+    });
+  });
+
   it("rejects unsafe activeBookId in the Studio agent API", async () => {
   it("rejects unsafe activeBookId in the Studio agent API", async () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);

+ 100 - 3
packages/studio/src/api/server.ts

@@ -32,6 +32,7 @@ import {
   listModelsForService,
   listModelsForService,
   getAllEndpoints,
   getAllEndpoints,
   probeModelsFromUpstream,
   probeModelsFromUpstream,
+  fetchWithProxy,
   chatCompletion,
   chatCompletion,
   buildExportArtifact,
   buildExportArtifact,
   GLOBAL_ENV_PATH,
   GLOBAL_ENV_PATH,
@@ -142,6 +143,65 @@ function extractToolError(result: unknown): string {
   return String(result).slice(0, 500);
   return String(result).slice(0, 500);
 }
 }
 
 
+function isLikelyFailedToolResult(exec: CollectedToolExec): boolean {
+  if (exec.status === "error") return true;
+  const text = `${exec.error ?? ""}\n${exec.result ?? ""}`.toLowerCase();
+  return /\bfailed\b|\berror\b|失败|异常|出错/.test(text);
+}
+
+function hasSuccessfulSubAgentExec(
+  execs: ReadonlyArray<CollectedToolExec>,
+  agent: string,
+): boolean {
+  return execs.some((exec) =>
+    exec.tool === "sub_agent"
+    && exec.agent === agent
+    && exec.status === "completed"
+    && !isLikelyFailedToolResult(exec)
+  );
+}
+
+function isWriteNextInstruction(instruction: string): boolean {
+  const trimmed = instruction.trim();
+  return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
+    || /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
+}
+
+function looksLikeBookCreatedClaim(responseText: string): boolean {
+  return /(?:已|已经|成功).{0,12}(?:创建|建书|初始化|保存).{0,12}(?:作品|书|书籍|文件夹)?/.test(responseText)
+    || /\b(?:created|initiali[sz]ed|saved)\b.{0,40}\b(?:book|project|novel)\b/i.test(responseText);
+}
+
+function validateAgentActionExecution(args: {
+  readonly instruction: string;
+  readonly agentBookId: string | null | undefined;
+  readonly responseText: string;
+  readonly collectedToolExecs: ReadonlyArray<CollectedToolExec>;
+}): string | undefined {
+  const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
+  if (failedExec) {
+    return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
+  }
+
+  if (
+    args.agentBookId
+    && isWriteNextInstruction(args.instruction)
+    && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")
+  ) {
+    return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
+  }
+
+  if (
+    !args.agentBookId
+    && looksLikeBookCreatedClaim(args.responseText)
+    && !resolveCreatedBookIdFromToolExecs(args.collectedToolExecs)
+  ) {
+    return "模型声称已创建作品,但没有实际调用建书工具,也没有生成作品文件。请补充书名/题材后重试,或换用支持工具调用的模型。";
+  }
+
+  return undefined;
+}
+
 interface CollectedToolExec {
 interface CollectedToolExec {
   id: string;
   id: string;
   tool: string;
   tool: string;
@@ -538,6 +598,7 @@ async function fetchModelsFromServiceBaseUrl(
   serviceId: string,
   serviceId: string,
   baseUrl: string,
   baseUrl: string,
   apiKey: string,
   apiKey: string,
+  proxyUrl?: string,
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
   const endpoint = isCustomServiceId(serviceId)
   const endpoint = isCustomServiceId(serviceId)
     ? undefined
     ? undefined
@@ -547,10 +608,10 @@ async function fetchModelsFromServiceBaseUrl(
     : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
     : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
   try {
-    const res = await fetch(modelsUrl, {
+    const res = await fetchWithProxy(modelsUrl, {
       headers: { Authorization: `Bearer ${apiKey}` },
       headers: { Authorization: `Bearer ${apiKey}` },
       signal: AbortSignal.timeout(10_000),
       signal: AbortSignal.timeout(10_000),
-    });
+    }, proxyUrl);
     if (!res.ok) {
     if (!res.ok) {
       const body = await res.text().catch(() => "");
       const body = await res.text().catch(() => "");
       return {
       return {
@@ -579,6 +640,7 @@ async function probeServiceCapabilities(args: {
   preferredApiFormat?: "chat" | "responses";
   preferredApiFormat?: "chat" | "responses";
   preferredStream?: boolean;
   preferredStream?: boolean;
   preferredModel?: string;
   preferredModel?: string;
+  proxyUrl?: string;
 }): Promise<ServiceProbeResult> {
 }): Promise<ServiceProbeResult> {
   const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
   const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
   const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
   const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
@@ -590,7 +652,7 @@ async function probeServiceCapabilities(args: {
       : null;
       : null;
 
 
   const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
   const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
-  const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
+  const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
   if (modelsResponse.authFailed) {
   if (modelsResponse.authFailed) {
     return {
     return {
       ok: false,
       ok: false,
@@ -646,6 +708,7 @@ async function probeServiceCapabilities(args: {
         temperature: 0.7,
         temperature: 0.7,
         maxTokens: 2048,
         maxTokens: 2048,
         thinkingBudget: 0,
         thinkingBudget: 0,
+        proxyUrl: args.proxyUrl,
         apiFormat: plan.apiFormat,
         apiFormat: plan.apiFormat,
         stream: plan.stream,
         stream: plan.stream,
       } as ProjectConfig["llm"]);
       } as ProjectConfig["llm"]);
@@ -776,6 +839,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       model: overrides?.model ?? currentConfig.llm.model,
       model: overrides?.model ?? currentConfig.llm.model,
       projectRoot: root,
       projectRoot: root,
       defaultLLMConfig: currentConfig.llm,
       defaultLLMConfig: currentConfig.llm,
+      foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
       modelOverrides: currentConfig.modelOverrides,
       modelOverrides: currentConfig.modelOverrides,
       notifyChannels: currentConfig.notify,
       notifyChannels: currentConfig.notify,
       logger,
       logger,
@@ -1208,6 +1272,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const envConfig = await readEnvConfigStatus(root);
     const envConfig = await readEnvConfigStatus(root);
     return c.json({
     return c.json({
       services,
       services,
+      service: typeof llm.service === "string" ? llm.service : null,
       defaultModel: llm.defaultModel ?? null,
       defaultModel: llm.defaultModel ?? null,
       configSource: "studio" satisfies LLMConfigSource,
       configSource: "studio" satisfies LLMConfigSource,
       storedConfigSource: normalizeConfigSource(llm.configSource),
       storedConfigSource: normalizeConfigSource(llm.configSource),
@@ -1261,6 +1326,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
       return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
     }
     }
 
 
+    const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
+    const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
     const probe = await probeServiceCapabilities({
     const probe = await probeServiceCapabilities({
       root,
       root,
       service,
       service,
@@ -1268,6 +1335,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       baseUrl: resolvedBaseUrl,
       baseUrl: resolvedBaseUrl,
       preferredApiFormat: apiFormat,
       preferredApiFormat: apiFormat,
       preferredStream: stream,
       preferredStream: stream,
+      proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
     });
     });
 
 
     // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
     // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
@@ -1932,6 +2000,21 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         instruction,
         instruction,
       );
       );
 
 
+      if (result.responseText) {
+        const actionExecutionError = validateAgentActionExecution({
+          instruction,
+          agentBookId,
+          responseText: result.responseText,
+          collectedToolExecs,
+        });
+        if (actionExecutionError) {
+          return c.json({
+            error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
+            response: actionExecutionError,
+          }, 502);
+        }
+      }
+
       let broadcastedCreatedBookId: string | null = null;
       let broadcastedCreatedBookId: string | null = null;
       const finalizeCreatedBook = async (): Promise<string | null> => {
       const finalizeCreatedBook = async (): Promise<string | null> => {
         if (agentBookId) return null;
         if (agentBookId) return null;
@@ -1992,6 +2075,18 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             { maxTokens: 256 },
             { maxTokens: 256 },
           );
           );
           if (fallback.content?.trim()) {
           if (fallback.content?.trim()) {
+            const actionExecutionError = validateAgentActionExecution({
+              instruction,
+              agentBookId,
+              responseText: fallback.content,
+              collectedToolExecs,
+            });
+            if (actionExecutionError) {
+              return c.json({
+                error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
+                response: actionExecutionError,
+              }, 502);
+            }
             await appendManualSessionMessages(root, bookSession.sessionId, [{
             await appendManualSessionMessages(root, bookSession.sessionId, [{
               role: "assistant",
               role: "assistant",
               content: [{ type: "text", text: fallback.content }],
               content: [{ type: "text", text: fallback.content }],
@@ -2613,6 +2708,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/v1/books/:id/style/import", async (c) => {
   app.post("/api/v1/books/:id/style/import", async (c) => {
     const id = c.req.param("id");
     const id = c.req.param("id");
     const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
     const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
+    if (!text?.trim()) return c.json({ error: "text is required" }, 400);
 
 
     broadcast("style:start", { bookId: id });
     broadcast("style:start", { bookId: id });
     try {
     try {
@@ -2787,6 +2883,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         preferredApiFormat: currentConfig.llm.apiFormat,
         preferredApiFormat: currentConfig.llm.apiFormat,
         preferredStream: currentConfig.llm.stream,
         preferredStream: currentConfig.llm.stream,
         preferredModel: currentConfig.llm.model,
         preferredModel: currentConfig.llm.model,
+        proxyUrl: currentConfig.llm.proxyUrl,
       });
       });
       checks.llmConnected = probe.ok;
       checks.llmConnected = probe.ok;
     } catch { /* ignore */ }
     } catch { /* ignore */ }

+ 50 - 5
packages/studio/src/pages/ChatPage.tsx

@@ -2,6 +2,7 @@ import { useRef, useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { SSEMessage } from "../hooks/use-sse";
+import { fetchJson } from "../hooks/use-api";
 import { chatSelectors, useChatStore } from "../store/chat";
 import { chatSelectors, useChatStore } from "../store/chat";
 import { useServiceStore } from "../store/service";
 import { useServiceStore } from "../store/service";
 import {
 import {
@@ -31,6 +32,7 @@ import {
   MessageContent,
   MessageContent,
 } from "../components/ai-elements/message";
 } from "../components/ai-elements/message";
 import {
 import {
+  type ChatPageModelPreference,
   filterModelGroups,
   filterModelGroups,
   getBookCreateSessionId,
   getBookCreateSessionId,
   pickModelSelection,
   pickModelSelection,
@@ -53,6 +55,11 @@ export interface ChatPageProps {
   readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
   readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
 }
 }
 
 
+interface ServiceConfigPayload {
+  readonly service?: string | null;
+  readonly defaultModel?: string | null;
+}
+
 // -- Component --
 // -- Component --
 
 
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
@@ -96,12 +103,36 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
   const fetchServices = useServiceStore((s) => s.fetchServices);
   const fetchServices = useServiceStore((s) => s.fetchServices);
   const fetchBankModels = useServiceStore((s) => s.fetchBankModels);
   const fetchBankModels = useServiceStore((s) => s.fetchBankModels);
   const fetchCustomModels = useServiceStore((s) => s.fetchCustomModels);
   const fetchCustomModels = useServiceStore((s) => s.fetchCustomModels);
+  const [configuredModelSelection, setConfiguredModelSelection] = useState<ChatPageModelPreference | null>(null);
+  const [serviceConfigLoaded, setServiceConfigLoaded] = useState(false);
 
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
   useEffect(() => { void fetchServices(); }, [fetchServices]);
   useEffect(() => {
   useEffect(() => {
     void fetchBankModels();
     void fetchBankModels();
     void fetchCustomModels();
     void fetchCustomModels();
   }, [fetchBankModels, fetchCustomModels]);
   }, [fetchBankModels, fetchCustomModels]);
+  useEffect(() => {
+    let cancelled = false;
+
+    void fetchJson<ServiceConfigPayload>("/services/config")
+      .then((payload) => {
+        if (cancelled) return;
+        setConfiguredModelSelection({
+          service: payload.service ?? null,
+          model: payload.defaultModel ?? null,
+        });
+      })
+      .catch(() => {
+        if (!cancelled) setConfiguredModelSelection(null);
+      })
+      .finally(() => {
+        if (!cancelled) setServiceConfigLoaded(true);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, []);
 
 
   const modelPickerStatus = useMemo(() => {
   const modelPickerStatus = useMemo(() => {
     if (servicesLoading || services.length === 0) return "loading" as const;
     if (servicesLoading || services.length === 0) return "loading" as const;
@@ -121,13 +152,27 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
       .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]! }));
       .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]! }));
   }, [services, modelsByService]);
   }, [services, modelsByService]);
 
 
-  // Auto-select first model when models load, and replace stale selections after lists refresh.
+  const selectedModelLabel = useMemo(() => {
+    if (!selectedModel) return "选择模型";
+    const group = groupedModels.find((item) => item.service === selectedService);
+    const model = group?.models.find((item) => item.id === selectedModel);
+    const modelLabel = model?.name ?? selectedModel;
+    return group ? `${group.label} · ${modelLabel}` : modelLabel;
+  }, [groupedModels, selectedModel, selectedService]);
+
+  // Auto-select from saved service config first, then fall back to the first available model.
   useEffect(() => {
   useEffect(() => {
-    const nextSelection = pickModelSelection(groupedModels, selectedModel, selectedService);
+    if (!serviceConfigLoaded) return;
+    const nextSelection = pickModelSelection(
+      groupedModels,
+      selectedModel,
+      selectedService,
+      configuredModelSelection,
+    );
     if (nextSelection) {
     if (nextSelection) {
       setSelectedModel(nextSelection.model, nextSelection.service);
       setSelectedModel(nextSelection.model, nextSelection.service);
     }
     }
-  }, [groupedModels, selectedModel, selectedService, setSelectedModel]);
+  }, [configuredModelSelection, groupedModels, selectedModel, selectedService, serviceConfigLoaded, setSelectedModel]);
 
 
   // Auto-resize textarea
   // Auto-resize textarea
   useEffect(() => {
   useEffect(() => {
@@ -356,8 +401,8 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                 ) : modelPickerStatus === "ready" ? (
                 ) : modelPickerStatus === "ready" ? (
                   <DropdownMenu>
                   <DropdownMenu>
                     <DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted text-sm transition-colors cursor-pointer">
                     <DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted text-sm transition-colors cursor-pointer">
-                      <span className="font-medium text-xs truncate max-w-[140px]">
-                        {selectedModel ?? "选择模型"}
+                      <span className="font-medium text-xs truncate max-w-[220px]">
+                        {selectedModelLabel}
                       </span>
                       </span>
                       <ChevronDown size={14} className="text-muted-foreground" />
                       <ChevronDown size={14} className="text-muted-foreground" />
                     </DropdownMenuTrigger>
                     </DropdownMenuTrigger>

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

@@ -142,6 +142,33 @@ describe("pickModelSelection", () => {
     });
     });
   });
   });
 
 
+  it("prefers the configured service and model when there is no current selection", () => {
+    expect(pickModelSelection(grouped, null, null, {
+      service: "moonshot",
+      model: "kimi-k2.5",
+    })).toEqual({
+      model: "kimi-k2.5",
+      service: "moonshot",
+    });
+  });
+
+  it("prefers the configured service even when its configured model is stale", () => {
+    expect(pickModelSelection(grouped, null, null, {
+      service: "moonshot",
+      model: "kimi-k3",
+    })).toEqual({
+      model: "kimi-k2.5",
+      service: "moonshot",
+    });
+  });
+
+  it("keeps a valid user selection over the configured default", () => {
+    expect(pickModelSelection(grouped, "gemini-2.5-flash", "google", {
+      service: "moonshot",
+      model: "kimi-k2.5",
+    })).toBeNull();
+  });
+
   it("returns null when no models are available", () => {
   it("returns null when no models are available", () => {
     expect(pickModelSelection([], "gemini-3.1-flash-image-preview", "google")).toBeNull();
     expect(pickModelSelection([], "gemini-3.1-flash-image-preview", "google")).toBeNull();
   });
   });

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

@@ -9,6 +9,11 @@ export interface ChatPageModelGroup {
   readonly models: ReadonlyArray<ChatPageModelInfo>;
   readonly models: ReadonlyArray<ChatPageModelInfo>;
 }
 }
 
 
+export interface ChatPageModelPreference {
+  readonly model?: string | null;
+  readonly service?: string | null;
+}
+
 const BOOK_CREATE_SESSION_KEY = "inkos.book-create.session-id";
 const BOOK_CREATE_SESSION_KEY = "inkos.book-create.session-id";
 
 
 export function getBookCreateSessionId(): string | null {
 export function getBookCreateSessionId(): string | null {
@@ -45,6 +50,7 @@ export function pickModelSelection(
   groupedModels: ReadonlyArray<ChatPageModelGroup>,
   groupedModels: ReadonlyArray<ChatPageModelGroup>,
   selectedModel: string | null,
   selectedModel: string | null,
   selectedService: string | null,
   selectedService: string | null,
+  preference?: ChatPageModelPreference | null,
 ): { model: string; service: string } | null {
 ): { model: string; service: string } | null {
   const selectedStillAvailable = selectedModel && selectedService
   const selectedStillAvailable = selectedModel && selectedService
     ? groupedModels.some((group) =>
     ? groupedModels.some((group) =>
@@ -54,6 +60,29 @@ export function pickModelSelection(
     : false;
     : false;
   if (selectedStillAvailable) return null;
   if (selectedStillAvailable) return null;
 
 
+  const preferredService = preference?.service?.trim();
+  const preferredModel = preference?.model?.trim();
+  if (preferredService) {
+    const preferredGroup = groupedModels.find((group) => group.service === preferredService);
+    const exactModel = preferredModel
+      ? preferredGroup?.models.find((model) => model.id === preferredModel)
+      : undefined;
+    if (preferredGroup && exactModel) {
+      return { model: exactModel.id, service: preferredGroup.service };
+    }
+    const firstPreferredModel = preferredGroup?.models[0];
+    if (preferredGroup && firstPreferredModel) {
+      return { model: firstPreferredModel.id, service: preferredGroup.service };
+    }
+  }
+
+  if (preferredModel) {
+    for (const group of groupedModels) {
+      const exactModel = group.models.find((model) => model.id === preferredModel);
+      if (exactModel) return { model: exactModel.id, service: group.service };
+    }
+  }
+
   const firstGroup = groupedModels.find((group) => group.models.length > 0);
   const firstGroup = groupedModels.find((group) => group.models.length > 0);
   const firstModel = firstGroup?.models[0];
   const firstModel = firstGroup?.models[0];
   if (!firstGroup || !firstModel) return null;
   if (!firstGroup || !firstModel) return null;

+ 9 - 0
pnpm-lock.yaml

@@ -84,6 +84,9 @@ importers:
       js-yaml:
       js-yaml:
         specifier: ^4.1.1
         specifier: ^4.1.1
         version: 4.1.1
         version: 4.1.1
+      undici:
+        specifier: 6.21.3
+        version: 6.21.3
       zod:
       zod:
         specifier: ^3.25.76
         specifier: ^3.25.76
         version: 3.25.76
         version: 3.25.76
@@ -4557,6 +4560,10 @@ packages:
   undici-types@6.21.0:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
 
+  undici@6.21.3:
+    resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
+    engines: {node: '>=18.17'}
+
   undici@7.25.0:
   undici@7.25.0:
     resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
     resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
     engines: {node: '>=20.18.1'}
     engines: {node: '>=20.18.1'}
@@ -9973,6 +9980,8 @@ snapshots:
 
 
   undici-types@6.21.0: {}
   undici-types@6.21.0: {}
 
 
+  undici@6.21.3: {}
+
   undici@7.25.0: {}
   undici@7.25.0: {}
 
 
   unicode-emoji-modifier-base@1.0.0: {}
   unicode-emoji-modifier-base@1.0.0: {}