@actalk/inkos-studio 1.6.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/api/server.d.ts.map +1 -1
  2. package/dist/api/server.js +402 -113
  3. package/dist/api/server.js.map +1 -1
  4. package/dist/assets/FilmWizard-DMFBbaXo.js +1 -0
  5. package/dist/assets/FlowView-C1NMbKwK.js +7 -0
  6. package/dist/assets/{_baseUniq-B-ZYRDWx.js → _baseUniq-DSlQQdBD.js} +1 -1
  7. package/dist/assets/{arc-Ciw3FGtr.js → arc-Dns2Uhcf.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-mC_rz2X6.js → architectureDiagram-Q4EWVU46-C4o80QfP.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-D_GMBRzY.js → blockDiagram-DXYQGD6D-v8Jrngon.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-CvmmAOg5.js → c4Diagram-AHTNJAMY-B1Fujpc3.js} +1 -1
  11. package/dist/assets/channel-DYtAlgfX.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-BPr0k8sP.js → chunk-4BX2VUAB-BaFvLxj9.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-D-Y1Qj_T.js → chunk-4TB4RGXK-B-9uyAsr.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-Bug_ufgD.js → chunk-55IACEB6-DhZyrHsN.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-C7tD8L3k.js → chunk-EDXVE4YY-OD0vcvaC.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-BU7HNA1K.js → chunk-FMBD7UC4-Dp2GQRr5.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-DQ_CIv08.js → chunk-OYMX7WX6-D3r5Nwmb.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-HODyXosI.js → chunk-QZHKN3VN-DnYd3GKf.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-7Y8K_8MD.js → chunk-YZCP3GAM-DYexb8Gp.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DQWMsmEX.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DQWMsmEX.js +1 -0
  22. package/dist/assets/clone-Cax418xZ.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-D2kYbpZy.js → cose-bilkent-S5V4N54A-CVl0lUPg.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-BlyZQ0n0.js → dagre-KV5264BT-BjmDwv13.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-C3lJ6ObZ.js → diagram-5BDNPKRD-CPHkNiE-.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-hx-WGqJX.js → diagram-G4DWMVQ6-BhmoLr3O.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-CdI5Z_ZJ.js → diagram-MMDJMWI5-D16xeq7f.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-BmtYRsNe.js → diagram-TYMM5635-BaELRM0R.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-DPokxoLy.js → erDiagram-SMLLAGMA-B2aLymvv.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-DFnf6GAZ.js → flowDiagram-DWJPFMVM-DdKH_wgd.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-ymFoiBFD.js → ganttDiagram-T4ZO3ILL-B6bjg7qJ.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BOAZqylM.js → gitGraphDiagram-UUTBAWPF-CY2CyY-W.js} +1 -1
  33. package/dist/assets/{graph-BMumGBmN.js → graph-C0NBXw-9.js} +1 -1
  34. package/dist/assets/{highlighted-body-OFNGDK62-DRX5PypI.js → highlighted-body-OFNGDK62-CkWd2EdN.js} +1 -1
  35. package/dist/assets/index-BMp83egq.css +1 -0
  36. package/dist/assets/{index-MiIZL4f3.js → index-Bx0bFhkr.js} +326 -316
  37. package/dist/assets/{infoDiagram-42DDH7IO-D0iCgAlu.js → infoDiagram-42DDH7IO-D0Q7gFDt.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-UXIWVN3A-WtqHTEzb.js → ishikawaDiagram-UXIWVN3A-CrrH-P2c.js} +1 -1
  39. package/dist/assets/{journeyDiagram-VCZTEJTY-DUlKzcQ-.js → journeyDiagram-VCZTEJTY-C0fvpM7W.js} +1 -1
  40. package/dist/assets/{kanban-definition-6JOO6SKY-COYae0oO.js → kanban-definition-6JOO6SKY-CMM-2rp8.js} +1 -1
  41. package/dist/assets/{layout-Et_h2gsr.js → layout-B2QGzq_V.js} +1 -1
  42. package/dist/assets/{linear-DY_vot7D.js → linear-BQ-iBxey.js} +1 -1
  43. package/dist/assets/{min-CImNjYIc.js → min-D505DuPN.js} +1 -1
  44. package/dist/assets/{mindmap-definition-QFDTVHPH-zOJ7uKnl.js → mindmap-definition-QFDTVHPH-CYRf42pX.js} +2 -2
  45. package/dist/assets/{pieDiagram-DEJITSTG-BftexWKs.js → pieDiagram-DEJITSTG-CC8K2Mwc.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-34T5L4WZ-CHmM6MYD.js → quadrantDiagram-34T5L4WZ-DPopTEAi.js} +1 -1
  47. package/dist/assets/{requirementDiagram-MS252O5E-eMzrziUe.js → requirementDiagram-MS252O5E-C3ewlVOB.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-XADWPNL6-B-Bgmxmu.js → sankeyDiagram-XADWPNL6-B_OEE-2a.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-FGHM5R23-cFT-__W_.js → sequenceDiagram-FGHM5R23-DOcJot11.js} +1 -1
  50. package/dist/assets/{stateDiagram-FHFEXIEX-CVnHwqCo.js → stateDiagram-FHFEXIEX-CxQZgNz4.js} +1 -1
  51. package/dist/assets/stateDiagram-v2-QKLJ7IA2-D2VCh5-K.js +1 -0
  52. package/dist/assets/{timeline-definition-GMOUNBTQ-CKbNRuf9.js → timeline-definition-GMOUNBTQ-ZC7OAV2y.js} +1 -1
  53. package/dist/assets/{value-IOfiG7E7.js → value-Cr6rlNd8.js} +1 -1
  54. package/dist/assets/{vennDiagram-DHZGUBPP-BDoxftwQ.js → vennDiagram-DHZGUBPP-ClWvcTEE.js} +4 -4
  55. package/dist/assets/{wardley-RL74JXVD-5TXAieIJ.js → wardley-RL74JXVD-BGm2zW6Q.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-NUSXRM2D-CFpD2tIy.js → wardleyDiagram-NUSXRM2D-BKVs8NMK.js} +1 -1
  57. package/dist/assets/{xychartDiagram-5P7HB3ND-cP5C2vcw.js → xychartDiagram-5P7HB3ND-DTIdAaRC.js} +1 -1
  58. package/dist/index.html +2 -2
  59. package/package.json +2 -2
  60. package/dist/assets/FilmWizard-C_1W4acq.js +0 -1
  61. package/dist/assets/FlowView-DvrwPvo2.js +0 -7
  62. package/dist/assets/channel-F10GB4f_.js +0 -1
  63. package/dist/assets/classDiagram-6PBFFD2Q-BqBb7BDW.js +0 -1
  64. package/dist/assets/classDiagram-v2-HSJHXN6E-BqBb7BDW.js +0 -1
  65. package/dist/assets/clone-DOQmcAsj.js +0 -1
  66. package/dist/assets/index-BZMnkfN7.css +0 -1
  67. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nhhJvKGq.js +0 -1
@@ -3,54 +3,81 @@ import { cors } from "hono/cors";
3
3
  import { streamSSE } from "hono/streaming";
4
4
  import { serve } from "@hono/node-server";
5
5
  import { gzipSync } from "node:zlib";
6
- import { StateManager, PipelineRunner, createLLMClient, createLogger, createInteractionToolsFromDeps, computeAnalytics, loadProjectConfig, loadProjectSession, processProjectInteractionRequest, resolveSessionActiveBook, listBookSessions, loadBookSession, appendManualSessionMessages, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, abortAgentSession, runAgentSession, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, guessServiceFromBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, listModelsForService, isApiKeyOptionalForEndpoint, getAllEndpoints, probeModelsFromUpstream, fetchWithProxy, chatCompletion, buildExportArtifact, evaluateBookQuality, ConsolidatorAgent, DetectionConfigSchema, ResearchSearchConfigSchema, InputGovernanceModeSchema, GLOBAL_ENV_PATH, COVER_PROVIDER_PRESETS, createPlayDB, PlayStore, buildPlayEntityImagePrompt, buildPlaySceneImagePrompt, generatePlayImage, readPlayImageManifest, readPlayImageSettings, writePlayImageSettings, Scheduler, coverSecretKey, resolveCoverProviderPreset, SessionKindSchema, isExplicitWriteChapterCommand, isUsablePlayInitialScene, isWriteNextInstruction, normalizeActionSource as normalizeCoreActionSource, normalizeActionPayload as normalizeCoreActionPayload, normalizePlayMode as normalizeCorePlayMode, normalizeRequestedIntent as normalizeCoreRequestedIntent, normalizeSkillIdList as normalizeCoreSkillIdList, inferLanguage, createSkillRegistry, loadConfiguredCapabilitySkills, CapabilitySkillManifestSchema, getBuiltinPrompt, listBuiltinPromptPacks, listBuiltinPrompts, loadPromptPackPrompt, promptOverridePath, createGenerateCoverTool, createInteractiveFilmCreationTool, createPlayStartTool, createScriptCreationTool, createShortFictionRunTool, createStoryboardCreationTool, createSubAgentTool, createDraftStructureTool, createConnectChoiceTool, createRemoveNodeTool, filmLLMDepsFromClient, applyGraphDelta, loadStoryGraph, reviewStoryGraph, exportInk, buildPlayableHtml, analyzeEmotionalArcs, analyzePathDistribution, generateNodeImage, defaultNodeImageDeps, } from "@actalk/inkos-core";
6
+ import { StateManager, PipelineRunner, createLLMClient, createLogger, createInteractionToolsFromDeps, computeAnalytics, loadProjectConfig, loadProjectSession, processProjectInteractionRequest, resolveSessionActiveBook, listBookSessions, loadBookSession, appendManualSessionMessages, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, abortAgentSession, runAgentSession, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, guessServiceFromBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, listModelsForService, isApiKeyOptionalForEndpoint, getAllEndpoints, probeModelsFromUpstream, fetchWithProxy, chatCompletion, buildExportArtifact, evaluateBookQuality, ConsolidatorAgent, DetectionConfigSchema, ResearchSearchConfigSchema, InputGovernanceModeSchema, GLOBAL_ENV_PATH, COVER_PROVIDER_PRESETS, createPlayDB, PlayStore, buildPlayEntityImagePrompt, buildPlaySceneImagePrompt, generatePlayImage, readPlayImageManifest, readPlayImageSettings, writePlayImageSettings, Scheduler, coverSecretKey, resolveCoverProviderPreset, SessionKindSchema, isExplicitWriteChapterCommand, isUsablePlayInitialScene, isWriteNextInstruction, normalizeActionSource as normalizeCoreActionSource, normalizeActionPayload as normalizeCoreActionPayload, normalizePlayMode as normalizeCorePlayMode, normalizeRequestedIntent as normalizeCoreRequestedIntent, normalizeSkillIdList as normalizeCoreSkillIdList, inferLanguage, createSkillRegistry, loadConfiguredCapabilitySkills, CapabilitySkillManifestSchema, getBuiltinPrompt, listBuiltinPromptPacks, listBuiltinPrompts, loadPromptPackPrompt, promptOverridePath, toPosixPath, createGenerateCoverTool, createInteractiveFilmCreationTool, createPlayStartTool, createScriptCreationTool, createShortFictionRunTool, createStoryboardCreationTool, createTranslationCreateTool, createSubAgentTool, createDraftStructureTool, createConnectChoiceTool, createRemoveNodeTool, createLLMTranslationModel, createTranslationProjectFromFile, loadTranslationChapter, loadTranslationManifest, runTranslationProject, writeTranslationExport, filmLLMDepsFromClient, applyGraphDelta, loadStoryGraph, reviewStoryGraph, exportInk, buildPlayableHtml, analyzeEmotionalArcs, analyzePathDistribution, generateNodeImage, defaultNodeImageDeps, } from "@actalk/inkos-core";
7
7
  import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { isSafeBookId } from "./safety.js";
10
10
  import { ApiError } from "./errors.js";
11
11
  import { buildStudioBookConfig } from "./book-create.js";
12
- // -- Pipeline stage definitions per agent type --
12
+ function normalizeStudioLanguage(value) {
13
+ return value === "en" ? "en" : "zh";
14
+ }
15
+ function pick(lang, zh, en) {
16
+ return lang === "en" ? en : zh;
17
+ }
13
18
  const PIPELINE_STAGES = {
14
19
  writer: [
15
- "准备章节输入", "撰写章节草稿", "落盘最终章节",
16
- "生成最终真相文件", "校验真相文件变更", "同步记忆索引",
17
- "更新章节索引与快照",
20
+ { zh: "准备章节输入", en: "Prepare chapter input" },
21
+ { zh: "撰写章节草稿", en: "Write chapter draft" },
22
+ { zh: "落盘最终章节", en: "Save final chapter" },
23
+ { zh: "生成最终真相文件", en: "Generate final truth files" },
24
+ { zh: "校验真相文件变更", en: "Validate truth file changes" },
25
+ { zh: "同步记忆索引", en: "Sync memory index" },
26
+ { zh: "更新章节索引与快照", en: "Update chapter index and snapshot" },
18
27
  ],
19
28
  architect: [
20
- "生成基础设定", "保存书籍配置", "写入基础设定文件",
21
- "初始化控制文档", "创建初始快照",
29
+ { zh: "生成基础设定", en: "Generate foundation" },
30
+ { zh: "保存书籍配置", en: "Save book config" },
31
+ { zh: "写入基础设定文件", en: "Write foundation files" },
32
+ { zh: "初始化控制文档", en: "Initialize control documents" },
33
+ { zh: "创建初始快照", en: "Create initial snapshot" },
22
34
  ],
23
35
  reviser: [
24
- "加载修订上下文", "修订章节", "落盘修订结果",
25
- "更新索引与快照",
36
+ { zh: "加载修订上下文", en: "Load revision context" },
37
+ { zh: "修订章节", en: "Revise chapter" },
38
+ { zh: "落盘修订结果", en: "Save revision result" },
39
+ { zh: "更新索引与快照", en: "Update index and snapshot" },
26
40
  ],
27
- auditor: ["审计章节"],
41
+ auditor: [{ zh: "审计章节", en: "Audit chapter" }],
28
42
  };
43
+ function pipelineStages(agent, lang = "zh") {
44
+ return PIPELINE_STAGES[agent]?.map((stage) => pick(lang, stage.zh, stage.en));
45
+ }
29
46
  function attachmentDisposition(fileName) {
30
47
  const safeAscii = fileName.replace(/[^A-Za-z0-9._-]+/g, "_") || "download";
31
48
  return `attachment; filename="${safeAscii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
32
49
  }
33
50
  const AGENT_LABELS = {
34
- architect: "建书", writer: "写作", auditor: "审计",
35
- reviser: "修订", exporter: "导出",
51
+ architect: { zh: "建书", en: "Book setup" },
52
+ writer: { zh: "写作", en: "Writing" },
53
+ auditor: { zh: "审计", en: "Audit" },
54
+ reviser: { zh: "修订", en: "Revision" },
55
+ exporter: { zh: "导出", en: "Export" },
36
56
  };
37
57
  const TOOL_LABELS = {
38
- read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
39
- propose_action: "确认动作",
40
- short_fiction_run: "短篇生产",
41
- script_create: "剧本创作",
42
- storyboard_create: "分镜创作",
43
- interactive_film_create: "互动影游",
44
- generate_cover: "生成封面",
45
- play_edit: "编辑互动世界",
46
- play_start: "启动互动世界",
47
- play_revise: "重做互动回合",
48
- play_step: "推进互动世界",
58
+ read: { zh: "读取文件", en: "Read file" },
59
+ edit: { zh: "编辑文件", en: "Edit file" },
60
+ grep: { zh: "搜索", en: "Search" },
61
+ ls: { zh: "列目录", en: "List directory" },
62
+ propose_action: { zh: "确认动作", en: "Confirm action" },
63
+ short_fiction_run: { zh: "短篇生产", en: "Short fiction" },
64
+ script_create: { zh: "剧本创作", en: "Script creation" },
65
+ storyboard_create: { zh: "分镜创作", en: "Storyboard creation" },
66
+ interactive_film_create: { zh: "互动影游", en: "Interactive film" },
67
+ translation_create: { zh: "翻译项目", en: "Translation" },
68
+ generate_cover: { zh: "生成封面", en: "Cover generation" },
69
+ play_edit: { zh: "编辑互动世界", en: "Edit interactive world" },
70
+ play_start: { zh: "启动互动世界", en: "Start interactive world" },
71
+ play_revise: { zh: "重做互动回合", en: "Redo interactive turn" },
72
+ play_step: { zh: "推进互动世界", en: "Advance interactive world" },
49
73
  };
50
- function resolveToolLabel(tool, agent) {
51
- if (tool === "sub_agent" && agent)
52
- return AGENT_LABELS[agent] ?? agent;
53
- return TOOL_LABELS[tool] ?? tool;
74
+ function resolveToolLabel(tool, agent, lang = "zh") {
75
+ if (tool === "sub_agent" && agent) {
76
+ const label = AGENT_LABELS[agent];
77
+ return label ? pick(lang, label.zh, label.en) : agent;
78
+ }
79
+ const label = TOOL_LABELS[tool];
80
+ return label ? pick(lang, label.zh, label.en) : tool;
54
81
  }
55
82
  function summarizeResult(result) {
56
83
  if (typeof result === "string")
@@ -189,8 +216,8 @@ function normalizeApiBookId(value, fieldName) {
189
216
  }
190
217
  return bookId;
191
218
  }
192
- function nonTextModelMessage(modelId) {
193
- return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
219
+ function nonTextModelMessage(modelId, lang = "zh") {
220
+ return pick(lang, `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`, `Model ${modelId} is not suitable for text chat/writing. Pick a text model in the model selector, e.g. gemini-2.5-flash, gemini-2.5-pro, or the service's chat model.`);
194
221
  }
195
222
  function extractToolError(result) {
196
223
  if (typeof result === "string")
@@ -354,6 +381,7 @@ function normalizeStudioSkillId(value, field = "skillId") {
354
381
  const MAX_AGENT_ATTACHMENTS = 8;
355
382
  const MAX_AGENT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
356
383
  const MAX_AGENT_ATTACHMENT_TEXT_CHARS = 120_000;
384
+ const MAX_TRANSLATION_UPLOAD_BYTES = 80 * 1024 * 1024;
357
385
  function safeUploadFileName(value) {
358
386
  const trimmed = value.trim().replace(/[/\\\0]/g, "_").replace(/\s+/g, " ");
359
387
  const safe = trimmed.replace(/[^\p{L}\p{N}._ -]+/gu, "_").slice(0, 120).trim();
@@ -411,7 +439,7 @@ async function normalizeAgentAttachments(root, sessionId, value) {
411
439
  const storedName = `${Date.now()}-${index + 1}-${filename}`;
412
440
  const storedPath = join(uploadDir, storedName);
413
441
  await writeFile(storedPath, parsed.buffer);
414
- const relPath = relative(root, storedPath);
442
+ const relPath = toPosixPath(relative(root, storedPath));
415
443
  if (mimeType.startsWith("image/")) {
416
444
  out.push({
417
445
  id: payload.id || `${Date.now()}-${index}`,
@@ -451,6 +479,26 @@ async function normalizeAgentAttachments(root, sessionId, value) {
451
479
  }
452
480
  return out;
453
481
  }
482
+ async function storeTranslationUpload(root, payload) {
483
+ const filename = safeUploadFileName(payload.filename || "translation-source");
484
+ if (!payload.dataUrl) {
485
+ throw new ApiError(400, "INVALID_TRANSLATION_UPLOAD", "Translation upload is missing dataUrl");
486
+ }
487
+ const parsed = parseDataUrl(payload.dataUrl);
488
+ if (parsed.buffer.byteLength > MAX_TRANSLATION_UPLOAD_BYTES) {
489
+ throw new ApiError(413, "TRANSLATION_UPLOAD_TOO_LARGE", `${filename} exceeds ${MAX_TRANSLATION_UPLOAD_BYTES} bytes`);
490
+ }
491
+ const uploadDir = join(root, ".inkos", "uploads", "translation");
492
+ await mkdir(uploadDir, { recursive: true });
493
+ const storedName = `${Date.now()}-${filename}`;
494
+ const storedPath = join(uploadDir, storedName);
495
+ await writeFile(storedPath, parsed.buffer);
496
+ return {
497
+ storedPath: toPosixPath(relative(root, storedPath)),
498
+ size: parsed.buffer.byteLength,
499
+ mimeType: parsed.mimeType,
500
+ };
501
+ }
454
502
  function projectSkillsDir(root) {
455
503
  return join(root, ".inkos", "skills");
456
504
  }
@@ -554,7 +602,8 @@ async function toStudioPromptPackPrompt(root, prompt) {
554
602
  content: loaded.content,
555
603
  source: loaded.source,
556
604
  overridden: loaded.source === "project",
557
- path: loaded.source === "project" ? relative(root, overridePath) : undefined,
605
+ // Windows relative() 产生反斜杠,这个 path 会被前端展示/断言为 posix 相对路径
606
+ path: loaded.source === "project" ? toPosixPath(relative(root, overridePath)) : undefined,
558
607
  };
559
608
  }
560
609
  function normalizeStudioPromptId(value) {
@@ -790,28 +839,30 @@ async function tryHandleExternalChatEdit(params) {
790
839
  };
791
840
  }
792
841
  function validateAgentActionExecution(args) {
842
+ const lang = args.language ?? "zh";
793
843
  const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
794
844
  if (failedExec) {
795
- return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
845
+ const detail = failedExec.error ?? failedExec.result ?? pick(lang, "未知错误", "unknown error");
846
+ return pick(lang, `${failedExec.label} 执行失败:${detail}`, `${failedExec.label} failed: ${detail}`);
796
847
  }
797
848
  if (args.agentBookId
798
849
  && args.requestedIntent === "write_next"
799
850
  && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")) {
800
- return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
851
+ return pick(lang, "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。", "The model claimed the next chapter is done, but it never called the writing tool. Retry; if it keeps failing, check whether the model supports tool calls.");
801
852
  }
802
853
  if (!args.agentBookId
803
854
  && args.requestedIntent === "create_book"
804
855
  && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "architect")) {
805
- return "已确认建书,但模型没有实际调用建书工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
856
+ return pick(lang, "已确认建书,但模型没有实际调用建书工具。请重试;如果仍失败,请检查模型是否支持工具调用。", "Book creation was confirmed, but the model never called the book setup tool. Retry; if it keeps failing, check whether the model supports tool calls.");
806
857
  }
807
858
  if (args.requestedIntent === "short_run" && !hasSuccessfulToolExec(args.collectedToolExecs, "short_fiction_run")) {
808
- return "已确认生成短篇,但模型没有实际调用短篇生产工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
859
+ return pick(lang, "已确认生成短篇,但模型没有实际调用短篇生产工具。请重试;如果仍失败,请检查模型是否支持工具调用。", "Short fiction was confirmed, but the model never called the short fiction tool. Retry; if it keeps failing, check whether the model supports tool calls.");
809
860
  }
810
861
  if (args.requestedIntent === "play_start" && !hasSuccessfulToolExec(args.collectedToolExecs, "play_start")) {
811
- return "已确认启动互动世界,但模型没有实际调用互动世界工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
862
+ return pick(lang, "已确认启动互动世界,但模型没有实际调用互动世界工具。请重试;如果仍失败,请检查模型是否支持工具调用。", "Starting the interactive world was confirmed, but the model never called the interactive world tool. Retry; if it keeps failing, check whether the model supports tool calls.");
812
863
  }
813
864
  if (args.requestedIntent === "generate_cover" && !hasSuccessfulToolExec(args.collectedToolExecs, "generate_cover")) {
814
- return "已确认生成封面,但模型没有实际调用封面工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
865
+ return pick(lang, "已确认生成封面,但模型没有实际调用封面工具。请重试;如果仍失败,请检查模型是否支持工具调用。", "Cover generation was confirmed, but the model never called the cover tool. Retry; if it keeps failing, check whether the model supports tool calls.");
815
866
  }
816
867
  return undefined;
817
868
  }
@@ -819,6 +870,9 @@ function classifyAgentFailure(message) {
819
870
  const text = message.trim();
820
871
  if (!text)
821
872
  return "unknown";
873
+ if (/BookWriteLockError|locked by an active InkOS write|BOOK_BUSY/i.test(text)) {
874
+ return "busy";
875
+ }
822
876
  if (/API\s*返回|上游|upstream|Bad Gateway|temporarily unavailable|rate limit|quota|API Key|unauthorized|forbidden|无法连接到 API|fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|LLM returned empty response|Provider finish_reason|reasoning_content/i.test(text)) {
823
877
  return "llm";
824
878
  }
@@ -827,16 +881,29 @@ function classifyAgentFailure(message) {
827
881
  }
828
882
  return "unknown";
829
883
  }
830
- function formatAgentFailure(message) {
884
+ function formatAgentFailure(message, lang = "zh") {
831
885
  const kind = classifyAgentFailure(message);
886
+ if (kind === "busy") {
887
+ return { code: "BOOK_BUSY", message, status: 409 };
888
+ }
832
889
  if (kind === "llm") {
833
890
  return { code: "AGENT_LLM_ERROR", message, status: 502 };
834
891
  }
835
892
  if (kind === "internal") {
836
- return { code: "AGENT_INTERNAL_ERROR", message: `InkOS 内部流程错误:${message}`, status: 500 };
893
+ return {
894
+ code: "AGENT_INTERNAL_ERROR",
895
+ message: pick(lang, `InkOS 内部流程错误:${message}`, `InkOS internal pipeline error: ${message}`),
896
+ status: 500,
897
+ };
837
898
  }
838
899
  return { code: "AGENT_ERROR", message, status: 500 };
839
900
  }
901
+ function formatAgentActionFailure(message, lang) {
902
+ const failure = formatAgentFailure(message, lang);
903
+ return failure.code === "BOOK_BUSY"
904
+ ? { code: failure.code, message: failure.message, status: 409 }
905
+ : { code: "AGENT_ACTION_FAILED", message, status: 502 };
906
+ }
840
907
  class ConfirmedActionExecutionError extends Error {
841
908
  exec;
842
909
  constructor(message, exec, cause) {
@@ -888,6 +955,7 @@ function isConfirmedProductionAction(args) {
888
955
  || args.requestedIntent === "script_create"
889
956
  || args.requestedIntent === "storyboard_create"
890
957
  || args.requestedIntent === "interactive_film_create"
958
+ || args.requestedIntent === "translation_create"
891
959
  || args.requestedIntent === "play_start"
892
960
  || args.requestedIntent === "generate_cover"
893
961
  || args.requestedIntent === "draft_structure"
@@ -901,11 +969,12 @@ function requirePayloadText(value, message) {
901
969
  }
902
970
  return text;
903
971
  }
904
- function toolResultText(result) {
972
+ function toolResultText(result, lang = "zh") {
905
973
  const text = extractToolError(result).trim();
906
- return text || "已完成。";
974
+ return text || pick(lang, "已完成。", "Done.");
907
975
  }
908
976
  async function executeConfirmedProductionAction(args) {
977
+ const lang = args.language ?? "zh";
909
978
  const id = `direct-${args.requestedIntent}-${Date.now().toString(36)}`;
910
979
  const actionPayload = args.actionPayload;
911
980
  let tool;
@@ -913,7 +982,7 @@ async function executeConfirmedProductionAction(args) {
913
982
  let agent;
914
983
  if (args.requestedIntent === "create_book") {
915
984
  const payload = actionPayload?.createBook;
916
- const title = requirePayloadText(payload?.title, "确认建书缺少书名,请重新生成确认卡。");
985
+ const title = requirePayloadText(payload?.title, pick(lang, "确认建书缺少书名,请重新生成确认卡。", "The book creation confirmation is missing a title. Regenerate the confirmation card."));
917
986
  tool = createSubAgentTool(args.pipeline, null, args.root, { actionPayload });
918
987
  agent = "architect";
919
988
  params = {
@@ -931,7 +1000,7 @@ async function executeConfirmedProductionAction(args) {
931
1000
  const payload = actionPayload?.shortRun;
932
1001
  const direction = payload?.direction?.trim() || args.instruction.trim();
933
1002
  if (!direction)
934
- throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认短篇缺少方向,请重新生成确认卡。");
1003
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认短篇缺少方向,请重新生成确认卡。", "The short fiction confirmation is missing a direction. Regenerate the confirmation card."));
935
1004
  tool = createShortFictionRunTool(args.pipeline, args.root, { actionPayload });
936
1005
  params = {
937
1006
  direction,
@@ -944,7 +1013,7 @@ async function executeConfirmedProductionAction(args) {
944
1013
  }
945
1014
  else if (args.requestedIntent === "generate_cover") {
946
1015
  const payload = actionPayload?.generateCover;
947
- const title = requirePayloadText(payload?.title, "确认生成封面缺少标题,请重新生成确认卡。");
1016
+ const title = requirePayloadText(payload?.title, pick(lang, "确认生成封面缺少标题,请重新生成确认卡。", "The cover generation confirmation is missing a title. Regenerate the confirmation card."));
948
1017
  tool = createGenerateCoverTool(args.root, { actionPayload });
949
1018
  params = {
950
1019
  title,
@@ -956,7 +1025,7 @@ async function executeConfirmedProductionAction(args) {
956
1025
  }
957
1026
  else if (args.requestedIntent === "script_create") {
958
1027
  const payload = actionPayload?.scriptCreate;
959
- const title = requirePayloadText(payload?.title, "确认创建剧本缺少标题,请重新生成确认卡。");
1028
+ const title = requirePayloadText(payload?.title, pick(lang, "确认创建剧本缺少标题,请重新生成确认卡。", "The script creation confirmation is missing a title. Regenerate the confirmation card."));
960
1029
  tool = createScriptCreationTool(args.pipeline, args.root, { actionPayload });
961
1030
  params = {
962
1031
  title,
@@ -974,7 +1043,7 @@ async function executeConfirmedProductionAction(args) {
974
1043
  }
975
1044
  else if (args.requestedIntent === "storyboard_create") {
976
1045
  const payload = actionPayload?.storyboardCreate;
977
- const title = requirePayloadText(payload?.title, "确认创建分镜缺少标题,请重新生成确认卡。");
1046
+ const title = requirePayloadText(payload?.title, pick(lang, "确认创建分镜缺少标题,请重新生成确认卡。", "The storyboard creation confirmation is missing a title. Regenerate the confirmation card."));
978
1047
  tool = createStoryboardCreationTool(args.pipeline, args.root, { actionPayload });
979
1048
  params = {
980
1049
  title,
@@ -993,7 +1062,7 @@ async function executeConfirmedProductionAction(args) {
993
1062
  }
994
1063
  else if (args.requestedIntent === "interactive_film_create") {
995
1064
  const payload = actionPayload?.interactiveFilmCreate;
996
- const title = requirePayloadText(payload?.title, "确认创建互动影游缺少标题,请重新生成确认卡。");
1065
+ const title = requirePayloadText(payload?.title, pick(lang, "确认创建互动影游缺少标题,请重新生成确认卡。", "The interactive film confirmation is missing a title. Regenerate the confirmation card."));
997
1066
  tool = createInteractiveFilmCreationTool(args.pipeline, args.root, { actionPayload });
998
1067
  params = {
999
1068
  title,
@@ -1011,9 +1080,23 @@ async function executeConfirmedProductionAction(args) {
1011
1080
  ...(payload?.outDir ? { outDir: payload.outDir } : {}),
1012
1081
  };
1013
1082
  }
1083
+ else if (args.requestedIntent === "translation_create") {
1084
+ const payload = actionPayload?.translationCreate;
1085
+ const filePath = requirePayloadText(payload?.filePath, pick(lang, "确认创建翻译项目缺少文件路径,请重新生成确认卡。", "The translation confirmation is missing a file path. Regenerate the confirmation card."));
1086
+ const sourceLanguage = requirePayloadText(payload?.sourceLanguage, pick(lang, "确认创建翻译项目缺少源语言,请重新生成确认卡。", "The translation confirmation is missing a source language. Regenerate the confirmation card."));
1087
+ const targetLanguage = requirePayloadText(payload?.targetLanguage, pick(lang, "确认创建翻译项目缺少目标语言,请重新生成确认卡。", "The translation confirmation is missing a target language. Regenerate the confirmation card."));
1088
+ tool = createTranslationCreateTool(args.root, { actionPayload });
1089
+ params = {
1090
+ filePath,
1091
+ sourceLanguage,
1092
+ targetLanguage,
1093
+ ...(payload?.title ? { title: payload.title } : {}),
1094
+ ...(payload?.segmentMaxChars ? { segmentMaxChars: payload.segmentMaxChars } : {}),
1095
+ };
1096
+ }
1014
1097
  else if (args.requestedIntent === "play_start") {
1015
1098
  const payload = actionPayload?.playStart;
1016
- const title = requirePayloadText(payload?.title, "确认启动互动世界缺少标题,请重新生成确认卡。");
1099
+ const title = requirePayloadText(payload?.title, pick(lang, "确认启动互动世界缺少标题,请重新生成确认卡。", "The interactive world start confirmation is missing a title. Regenerate the confirmation card."));
1017
1100
  const fallbackScene = [payload?.premise, args.instruction].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n\n");
1018
1101
  const initialScene = isUsablePlayInitialScene(payload?.initialScene)
1019
1102
  ? payload?.initialScene?.trim()
@@ -1046,7 +1129,7 @@ async function executeConfirmedProductionAction(args) {
1046
1129
  throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
1047
1130
  const agentCtx = args.pipeline.createAgentContext("film-authoring", projectId);
1048
1131
  const deps = filmLLMDepsFromClient(agentCtx.client, agentCtx.model);
1049
- tool = createDraftStructureTool(args.root, projectId, deps);
1132
+ tool = createDraftStructureTool(args.root, projectId, deps, lang);
1050
1133
  params = {
1051
1134
  instruction: payload?.instruction?.trim() || args.instruction,
1052
1135
  };
@@ -1054,7 +1137,7 @@ async function executeConfirmedProductionAction(args) {
1054
1137
  else if (args.requestedIntent === "connect_choice") {
1055
1138
  const payload = actionPayload?.connectChoice;
1056
1139
  if (!payload?.node) {
1057
- throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认连接选择缺少节点数据,请重新生成确认卡。");
1140
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认连接选择缺少节点数据,请重新生成确认卡。", "The connect-choice confirmation is missing node data. Regenerate the confirmation card."));
1058
1141
  }
1059
1142
  const projectId = payload?.projectId ?? args.bookId;
1060
1143
  if (!projectId)
@@ -1067,7 +1150,7 @@ async function executeConfirmedProductionAction(args) {
1067
1150
  else if (args.requestedIntent === "remove_node") {
1068
1151
  const payload = actionPayload?.removeNode;
1069
1152
  if (!payload?.nodeId) {
1070
- throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认删除节点缺少 nodeId,请重新生成确认卡。");
1153
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认删除节点缺少 nodeId,请重新生成确认卡。", "The remove-node confirmation is missing a nodeId. Regenerate the confirmation card."));
1071
1154
  }
1072
1155
  const projectId = payload?.projectId ?? args.bookId;
1073
1156
  if (!projectId)
@@ -1084,10 +1167,10 @@ async function executeConfirmedProductionAction(args) {
1084
1167
  id,
1085
1168
  tool: tool.name,
1086
1169
  agent,
1087
- label: resolveToolLabel(tool.name, agent),
1170
+ label: resolveToolLabel(tool.name, agent, lang),
1088
1171
  status: "running",
1089
1172
  args: params,
1090
- stages: agent ? PIPELINE_STAGES[agent]?.map(label => ({ label, status: "pending" })) : undefined,
1173
+ stages: agent ? pipelineStages(agent, lang)?.map(label => ({ label, status: "pending" })) : undefined,
1091
1174
  startedAt: Date.now(),
1092
1175
  };
1093
1176
  broadcast("tool:start", {
@@ -1107,7 +1190,7 @@ async function executeConfirmedProductionAction(args) {
1107
1190
  });
1108
1191
  exec.status = "completed";
1109
1192
  exec.completedAt = Date.now();
1110
- exec.result = toolResultText(result);
1193
+ exec.result = toolResultText(result, lang);
1111
1194
  exec.details = result?.details;
1112
1195
  exec.stages = exec.stages?.map(stage => ({ ...stage, status: "completed" }));
1113
1196
  broadcast("tool:end", {
@@ -1337,6 +1420,34 @@ async function resolveBookChapterReviewMode(root, bookId, projectMode) {
1337
1420
  return projectMode;
1338
1421
  }
1339
1422
  }
1423
+ function normalizeRevisionGate(gate) {
1424
+ return gate === "lenient" || gate === "always" ? gate : "strict";
1425
+ }
1426
+ function readProjectRevisionGate(config) {
1427
+ const writing = config.writing && typeof config.writing === "object" && !Array.isArray(config.writing)
1428
+ ? config.writing
1429
+ : {};
1430
+ return normalizeRevisionGate(writing.revisionGate);
1431
+ }
1432
+ function readBookRevisionGate(rawBook) {
1433
+ const writing = rawBook.writing && typeof rawBook.writing === "object" && !Array.isArray(rawBook.writing)
1434
+ ? rawBook.writing
1435
+ : undefined;
1436
+ if (!writing || writing.revisionGate !== "strict" && writing.revisionGate !== "lenient" && writing.revisionGate !== "always")
1437
+ return undefined;
1438
+ return writing.revisionGate;
1439
+ }
1440
+ async function resolveBookRevisionGate(root, bookId, projectGate) {
1441
+ if (!bookId || !isSafeBookId(bookId))
1442
+ return projectGate;
1443
+ try {
1444
+ const rawBook = await loadRawBookConfig(root, bookId);
1445
+ return readBookRevisionGate(rawBook) ?? projectGate;
1446
+ }
1447
+ catch {
1448
+ return projectGate;
1449
+ }
1450
+ }
1340
1451
  function unquoteEnvValue(value) {
1341
1452
  const trimmed = value.trim();
1342
1453
  if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
@@ -1558,13 +1669,13 @@ function fallbackTextModelsForEndpoint(endpoint, preset) {
1558
1669
  function shouldTrustStaticModelsWhenLiveListUnavailable(endpoint) {
1559
1670
  return endpoint?.group === "aggregator";
1560
1671
  }
1561
- async function withTimeout(promise, timeoutMs, label) {
1672
+ async function withTimeout(promise, timeoutMs, label, lang = "zh") {
1562
1673
  let timeout;
1563
1674
  try {
1564
1675
  return await Promise.race([
1565
1676
  promise,
1566
1677
  new Promise((_, reject) => {
1567
- timeout = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms)`)), timeoutMs);
1678
+ timeout = setTimeout(() => reject(new Error(pick(lang, `${label} 超时(${timeoutMs}ms)`, `${label} timed out (${timeoutMs}ms)`))), timeoutMs);
1568
1679
  }),
1569
1680
  ]);
1570
1681
  }
@@ -1574,49 +1685,55 @@ async function withTimeout(promise, timeoutMs, label) {
1574
1685
  }
1575
1686
  }
1576
1687
  function formatServiceProbeError(args) {
1688
+ const lang = args.language ?? "zh";
1577
1689
  const rawDetail = args.error
1578
1690
  .replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
1579
1691
  .trim();
1580
1692
  const upstreamDetail = rawDetail.includes("上游详情:")
1581
1693
  ? rawDetail
1582
1694
  : "";
1695
+ const protocol = args.apiFormat === "responses" ? "Responses" : "Chat / Completions";
1696
+ const streamSuffix = typeof args.stream === "boolean"
1697
+ ? pick(lang, `,${args.stream ? "流式" : "非流式"}`, `, ${args.stream ? "streaming" : "non-streaming"}`)
1698
+ : "";
1583
1699
  const context = [
1584
- `服务商:${args.label ?? args.service}`,
1585
- `测试模型:${args.model ?? "未确定"}`,
1586
- `协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
1587
- `Base URL:${args.baseUrl}`,
1700
+ pick(lang, `服务商:${args.label ?? args.service}`, `Service: ${args.label ?? args.service}`),
1701
+ pick(lang, `测试模型:${args.model ?? "未确定"}`, `Test model: ${args.model ?? "undetermined"}`),
1702
+ pick(lang, `协议:${protocol}${streamSuffix}`, `Protocol: ${protocol}${streamSuffix}`),
1703
+ pick(lang, `Base URL:${args.baseUrl}`, `Base URL: ${args.baseUrl}`),
1588
1704
  ].join("\n");
1705
+ const upstreamPrefix = (detail) => pick(lang, `\n上游返回:${detail}`, `\nUpstream response: ${detail}`);
1589
1706
  if (args.service === "google") {
1590
1707
  return [
1591
- "Google Gemini 测试连接失败。",
1708
+ pick(lang, "Google Gemini 测试连接失败。", "Google Gemini connection test failed."),
1592
1709
  context,
1593
1710
  "",
1594
- "请优先检查:",
1595
- "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
1596
- "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
1597
- "3. 当前地区/账号是否允许访问 Gemini API。",
1598
- "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
1599
- upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
1711
+ pick(lang, "请优先检查:", "Check these first:"),
1712
+ pick(lang, "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。", "1. The API Key is a Gemini API key from Google AI Studio, not an OAuth, Vertex AI, or other Google service credential."),
1713
+ pick(lang, "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。", "2. The key's project has the Gemini API enabled and is not restricted to other APIs, origins, or services."),
1714
+ pick(lang, "3. 当前地区/账号是否允许访问 Gemini API。", "3. Your region/account is allowed to access the Gemini API."),
1715
+ pick(lang, "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。", "4. If the key was ever leaked, regenerate it in AI Studio before saving."),
1716
+ upstreamDetail ? upstreamPrefix(upstreamDetail) : "",
1600
1717
  ].filter(Boolean).join("\n");
1601
1718
  }
1602
1719
  if (args.service === "moonshot" || args.service === "kimiCodingPlan" || args.service === "kimicode") {
1603
1720
  return [
1604
- `${args.label ?? args.service} 测试连接失败。`,
1721
+ pick(lang, `${args.label ?? args.service} 测试连接失败。`, `${args.label ?? args.service} connection test failed.`),
1605
1722
  context,
1606
1723
  "",
1607
- "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
1608
- rawDetail ? `\n上游返回:${rawDetail}` : "",
1724
+ pick(lang, "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。", "Check first whether the model is available, and whether models like kimi-k2.x require temperature=1."),
1725
+ rawDetail ? upstreamPrefix(rawDetail) : "",
1609
1726
  ].filter(Boolean).join("\n");
1610
1727
  }
1611
1728
  return [
1612
- `${args.label ?? args.service} 测试连接失败。`,
1729
+ pick(lang, `${args.label ?? args.service} 测试连接失败。`, `${args.label ?? args.service} connection test failed.`),
1613
1730
  context,
1614
1731
  "",
1615
- "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
1616
- rawDetail ? `\n上游返回:${rawDetail}` : "",
1732
+ pick(lang, "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。", "Check the API Key, model availability, account quota, and whether the protocol type matches this service."),
1733
+ rawDetail ? upstreamPrefix(rawDetail) : "",
1617
1734
  ].filter(Boolean).join("\n");
1618
1735
  }
1619
- async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey, proxyUrl) {
1736
+ async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey, proxyUrl, lang = "zh") {
1620
1737
  const endpoint = isCustomServiceId(serviceId)
1621
1738
  ? undefined
1622
1739
  : getAllEndpoints().find((ep) => ep.id === serviceId);
@@ -1626,14 +1743,14 @@ async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey, proxyUr
1626
1743
  const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
1627
1744
  try {
1628
1745
  const res = await fetchWithProxy(modelsUrl, {
1629
- headers: buildBearerAuthHeaders(apiKey),
1746
+ headers: buildBearerAuthHeaders(apiKey, lang),
1630
1747
  signal: AbortSignal.timeout(SERVICE_MODELS_PROBE_TIMEOUT_MS),
1631
1748
  }, proxyUrl);
1632
1749
  if (!res.ok) {
1633
1750
  const body = await res.text().catch(() => "");
1634
1751
  return {
1635
1752
  models: [],
1636
- error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`,
1753
+ error: pick(lang, `服务商返回 ${res.status}: ${body.slice(0, 200)}`, `Service returned ${res.status}: ${body.slice(0, 200)}`),
1637
1754
  authFailed: res.status === 401 || res.status === 403,
1638
1755
  };
1639
1756
  }
@@ -1649,16 +1766,17 @@ async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey, proxyUr
1649
1766
  };
1650
1767
  }
1651
1768
  }
1652
- function buildBearerAuthHeaders(apiKey) {
1769
+ function buildBearerAuthHeaders(apiKey, lang = "zh") {
1653
1770
  const trimmed = apiKey?.trim() ?? "";
1654
1771
  if (!trimmed)
1655
1772
  return {};
1656
1773
  if (!/^[\x20-\x7e]+$/.test(trimmed)) {
1657
- throw new Error("API Key 只能包含英文、数字和常见 ASCII 符号,请检查是否误粘贴了中文说明。");
1774
+ throw new Error(pick(lang, "API Key 只能包含英文、数字和常见 ASCII 符号,请检查是否误粘贴了中文说明。", "API Key may only contain ASCII letters, digits, and common symbols. Check whether you pasted explanatory text by mistake."));
1658
1775
  }
1659
1776
  return { Authorization: `Bearer ${trimmed}` };
1660
1777
  }
1661
1778
  async function probeServiceCapabilities(args) {
1779
+ const lang = args.language ?? "zh";
1662
1780
  const rawConfig = await loadRawConfig(args.root).catch(() => ({}));
1663
1781
  const llm = rawConfig.llm ?? {};
1664
1782
  const envConfig = await readEnvConfigStatus(args.root);
@@ -1668,12 +1786,12 @@ async function probeServiceCapabilities(args) {
1668
1786
  ? envConfig.global.model
1669
1787
  : null;
1670
1788
  const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
1671
- const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
1789
+ const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl, lang);
1672
1790
  if (modelsResponse.authFailed) {
1673
1791
  return {
1674
1792
  ok: false,
1675
1793
  models: [],
1676
- error: modelsResponse.error ?? "API Key 无效或无权访问模型列表。",
1794
+ error: modelsResponse.error ?? pick(lang, "API Key 无效或无权访问模型列表。", "API Key is invalid or has no access to the model list."),
1677
1795
  };
1678
1796
  }
1679
1797
  const discoveredModels = modelsResponse.models;
@@ -1686,7 +1804,7 @@ async function probeServiceCapabilities(args) {
1686
1804
  return {
1687
1805
  ok: false,
1688
1806
  models: discoveredModels,
1689
- error: "模型列表可访问,但没有发现可用于文本对话的模型。",
1807
+ error: pick(lang, "模型列表可访问,但没有发现可用于文本对话的模型。", "The model list is reachable, but no model usable for text chat was found."),
1690
1808
  };
1691
1809
  }
1692
1810
  return {
@@ -1745,10 +1863,10 @@ async function probeServiceCapabilities(args) {
1745
1863
  return {
1746
1864
  ok: false,
1747
1865
  models: [],
1748
- error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
1866
+ error: pick(lang, "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。", "Could not determine a model automatically. Fill in an available model first, or provide a service endpoint that supports /models."),
1749
1867
  };
1750
1868
  }
1751
- let lastError = modelsResponse.error ?? "自动探测失败";
1869
+ let lastError = modelsResponse.error ?? pick(lang, "自动探测失败", "Automatic probing failed");
1752
1870
  for (const model of modelCandidates) {
1753
1871
  for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
1754
1872
  const client = createLLMClient({
@@ -1770,7 +1888,7 @@ async function probeServiceCapabilities(args) {
1770
1888
  // A connectivity probe wants a fast pass/fail — never the transient
1771
1889
  // retry+backoff, which would multiply the time when the upstream is
1772
1890
  // rate-limiting (and make the diagnostics page hang).
1773
- chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16, retry: false }), SERVICE_CHAT_PROBE_TIMEOUT_MS, "service connection test");
1891
+ chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16, retry: false }), SERVICE_CHAT_PROBE_TIMEOUT_MS, "service connection test", lang);
1774
1892
  const models = discoveredModels.length > 0
1775
1893
  ? discoveredModels
1776
1894
  : fallbackTextModelsForEndpoint(endpoint, preset);
@@ -1793,6 +1911,7 @@ async function probeServiceCapabilities(args) {
1793
1911
  apiFormat: plan.apiFormat,
1794
1912
  stream: plan.stream,
1795
1913
  error: error instanceof Error ? error.message : String(error),
1914
+ language: lang,
1796
1915
  });
1797
1916
  }
1798
1917
  }
@@ -1859,10 +1978,19 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
1859
1978
  cachedConfig = freshConfig;
1860
1979
  return freshConfig;
1861
1980
  }
1981
+ // Read the project language fresh from inkos.json on every call, so a language
1982
+ // switch takes effect on the next request instead of being frozen at startup.
1983
+ // A missing/corrupt inkos.json means "no project language configured" -> zh.
1984
+ async function currentProjectLanguage() {
1985
+ const raw = await loadRawConfig(root).catch(() => ({}));
1986
+ return normalizeStudioLanguage(raw.language);
1987
+ }
1862
1988
  async function buildPipelineConfig(overrides) {
1863
1989
  const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
1864
1990
  const projectReviewMode = readProjectChapterReviewMode(currentConfig);
1865
1991
  const chapterReviewMode = await resolveBookChapterReviewMode(root, overrides?.bookIdForSettings, projectReviewMode);
1992
+ const projectRevisionGate = readProjectRevisionGate(currentConfig);
1993
+ const revisionGate = await resolveBookRevisionGate(root, overrides?.bookIdForSettings, projectRevisionGate);
1866
1994
  const scopedSseSink = overrides?.sessionIdForSSE
1867
1995
  ? {
1868
1996
  write(entry) {
@@ -1884,6 +2012,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
1884
2012
  foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
1885
2013
  writingReviewRetries: currentConfig.writing?.reviewRetries ?? 1,
1886
2014
  chapterReviewMode,
2015
+ revisionGate,
1887
2016
  modelOverrides: currentConfig.modelOverrides,
1888
2017
  notifyChannels: currentConfig.notify,
1889
2018
  logger,
@@ -2386,7 +2515,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2386
2515
  const env = await readEffectiveEnvConfigValues(root);
2387
2516
  if (!env || !env.values.apiKey) {
2388
2517
  return c.json({
2389
- error: "未检测到可导入的 LLM 环境变量配置,或缺少 INKOS_LLM_API_KEY。",
2518
+ error: pick(await currentProjectLanguage(), "未检测到可导入的 LLM 环境变量配置,或缺少 INKOS_LLM_API_KEY。", "No importable LLM environment variable configuration was detected, or INKOS_LLM_API_KEY is missing."),
2390
2519
  }, 400);
2391
2520
  }
2392
2521
  const config = await loadRawConfig(root);
@@ -2436,7 +2565,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2436
2565
  }
2437
2566
  if (body.configSource === "env") {
2438
2567
  return c.json({
2439
- error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
2568
+ error: pick(await currentProjectLanguage(), "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。", "The Studio runtime does not support switching to env; env only acts as an override layer in the CLI/daemon/deployment runtimes."),
2440
2569
  }, 400);
2441
2570
  }
2442
2571
  if (body.configSource !== undefined) {
@@ -2510,7 +2639,9 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2510
2639
  const body = await c.req.json();
2511
2640
  const trimmedKey = body.apiKey?.trim() ?? "";
2512
2641
  if (trimmedKey && !isHeaderSafeApiKey(trimmedKey)) {
2513
- return c.json({ error: "API Key 包含不能放入 HTTP Authorization header 的字符,请只粘贴原始密钥。" }, 400);
2642
+ return c.json({
2643
+ error: pick(await currentProjectLanguage(), "API Key 包含不能放入 HTTP Authorization header 的字符,请只粘贴原始密钥。", "API Key contains characters that cannot go into an HTTP Authorization header. Paste only the raw key."),
2644
+ }, 400);
2514
2645
  }
2515
2646
  const secrets = await loadSecrets(root);
2516
2647
  const key = coverSecretKey(service);
@@ -2547,9 +2678,13 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2547
2678
  app.post("/api/v1/services/:service/test", async (c) => {
2548
2679
  const service = c.req.param("service");
2549
2680
  const { apiKey, baseUrl, apiFormat, stream } = await c.req.json();
2681
+ const language = await currentProjectLanguage();
2550
2682
  const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
2551
2683
  if (!resolvedBaseUrl) {
2552
- return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
2684
+ return c.json({
2685
+ ok: false,
2686
+ error: pick(language, `未知服务商: ${service}`, `Unknown service: ${service}`),
2687
+ }, 400);
2553
2688
  }
2554
2689
  const baseService = isCustomServiceId(service) ? "custom" : service;
2555
2690
  const apiKeyOptional = isApiKeyOptionalForEndpoint({
@@ -2559,7 +2694,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2559
2694
  if (!apiKey?.trim() && !apiKeyOptional) {
2560
2695
  return c.json({
2561
2696
  ok: false,
2562
- error: "API Key 不能为空",
2697
+ error: pick(language, "API Key 不能为空", "API Key must not be empty"),
2563
2698
  }, 400);
2564
2699
  }
2565
2700
  const rawConfig = await loadRawConfig(root).catch(() => ({}));
@@ -2572,17 +2707,19 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2572
2707
  preferredApiFormat: apiFormat,
2573
2708
  preferredStream: stream,
2574
2709
  proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
2710
+ language,
2575
2711
  });
2576
2712
  // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
2713
+ const connectionFailed = pick(language, "连接失败", "Connection failed");
2577
2714
  const probeStatus = {
2578
2715
  ok: probe.ok,
2579
2716
  models: probe.models?.length ?? 0,
2580
- ...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
2717
+ ...(probe.ok ? {} : { error: probe.error ?? connectionFailed }),
2581
2718
  };
2582
2719
  if (!probe.ok) {
2583
2720
  return c.json({
2584
2721
  ok: false,
2585
- error: probe.error ?? "连接失败",
2722
+ error: probe.error ?? connectionFailed,
2586
2723
  probe: probeStatus,
2587
2724
  chat: null,
2588
2725
  }, 400);
@@ -2612,7 +2749,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
2612
2749
  if (!isHeaderSafeApiKey(trimmedKey)) {
2613
2750
  return c.json({
2614
2751
  ok: false,
2615
- error: "API Key 只能包含可放进 HTTP Authorization header 的非空白 ASCII 字符;请不要粘贴连接失败提示或诊断文本。",
2752
+ error: pick(await currentProjectLanguage(), "API Key 只能包含可放进 HTTP Authorization header 的非空白 ASCII 字符;请不要粘贴连接失败提示或诊断文本。", "API Key may only contain non-whitespace ASCII characters that fit in an HTTP Authorization header; do not paste connection failure hints or diagnostic text."),
2616
2753
  }, 400);
2617
2754
  }
2618
2755
  secrets.services[service] = { apiKey: trimmedKey };
@@ -3260,8 +3397,9 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3260
3397
  if (!sessionId?.trim()) {
3261
3398
  throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
3262
3399
  }
3400
+ const language = await currentProjectLanguage();
3263
3401
  if (reqModel && !isTextChatModelId(reqModel)) {
3264
- const message = nonTextModelMessage(reqModel);
3402
+ const message = nonTextModelMessage(reqModel, language);
3265
3403
  return c.json({ error: message, response: message }, 400);
3266
3404
  }
3267
3405
  const actionSource = normalizeStudioActionSource(reqActionSource);
@@ -3368,8 +3506,8 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3368
3506
  const msg = e?.message ?? String(e);
3369
3507
  if (/API key/i.test(msg)) {
3370
3508
  return c.json({
3371
- error: `请先为 ${reqService} 配置 API Key`,
3372
- response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
3509
+ error: pick(language, `请先为 ${reqService} 配置 API Key`, `Configure an API Key for ${reqService} first`),
3510
+ response: pick(language, `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`, `Fill in an API Key for ${reqService} in the model settings, then try again.`),
3373
3511
  }, 400);
3374
3512
  }
3375
3513
  throw e;
@@ -3463,6 +3601,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3463
3601
  instruction,
3464
3602
  requestedIntent,
3465
3603
  actionPayload,
3604
+ language,
3466
3605
  ...(playMode ? { playMode } : {}),
3467
3606
  });
3468
3607
  let createdBookId = null;
@@ -3489,7 +3628,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3489
3628
  });
3490
3629
  }
3491
3630
  }
3492
- const responseText = exec.result ?? "已完成。";
3631
+ const responseText = exec.result ?? pick(language, "已完成。", "Done.");
3493
3632
  const responseForUser = suppressManualTextForTool(exec) ? "" : responseText;
3494
3633
  await appendManualSessionMessages(root, bookSession.sessionId, [
3495
3634
  manualToolAssistantMessage(responseText, exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
@@ -3508,6 +3647,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3508
3647
  }
3509
3648
  catch (error) {
3510
3649
  const message = error instanceof Error ? error.message : String(error);
3650
+ const failure = formatAgentActionFailure(message, language);
3511
3651
  if (pendingBookId) {
3512
3652
  bookCreateStatus.set(pendingBookId, { status: "error", error: message });
3513
3653
  broadcast("book:error", { bookId: pendingBookId, sessionId: streamSessionId, error: message });
@@ -3520,9 +3660,9 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3520
3660
  }
3521
3661
  broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
3522
3662
  return c.json({
3523
- error: { code: "AGENT_ACTION_FAILED", message },
3524
- response: message,
3525
- }, 502);
3663
+ error: { code: failure.code, message: failure.message },
3664
+ response: failure.message,
3665
+ }, failure.status);
3526
3666
  }
3527
3667
  }
3528
3668
  if (shouldRunDirectWriteNext({ instruction, agentBookId, sessionKind, actionSource, requestedIntent })) {
@@ -3537,12 +3677,12 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3537
3677
  id: toolCallId,
3538
3678
  tool: "sub_agent",
3539
3679
  args: toolArgs,
3540
- stages: PIPELINE_STAGES.writer,
3680
+ stages: pipelineStages("writer", language),
3541
3681
  });
3542
3682
  try {
3543
3683
  const writeResult = await pipeline.writeNextChapter(directWriteBookId);
3544
3684
  const writeNeedsReview = Boolean(writeResult.status && writeResult.status !== "ready-for-review");
3545
- const responseText = writeNeedsReview
3685
+ const zhResponseText = writeNeedsReview
3546
3686
  ? [
3547
3687
  `已为 ${directWriteBookId} 写出第 ${writeResult.chapterNumber} 章`,
3548
3688
  writeResult.title ? `《${writeResult.title}》` : "",
@@ -3553,6 +3693,13 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3553
3693
  writeResult.title ? `《${writeResult.title}》` : "",
3554
3694
  `,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
3555
3695
  ].join("");
3696
+ const enChapterRef = writeResult.title
3697
+ ? `chapter ${writeResult.chapterNumber} "${writeResult.title}"`
3698
+ : `chapter ${writeResult.chapterNumber}`;
3699
+ const enResponseText = writeNeedsReview
3700
+ ? `Wrote ${enChapterRef} for ${directWriteBookId}: ${writeResult.wordCount} words, but the review did not pass (status: ${writeResult.status}). Manual review is required before continuing.`
3701
+ : `Completed ${enChapterRef} for ${directWriteBookId}: ${writeResult.wordCount} words, status ${writeResult.status}.`;
3702
+ const responseText = pick(language, zhResponseText, enResponseText);
3556
3703
  const toolResult = {
3557
3704
  content: [{ type: "text", text: responseText }],
3558
3705
  details: {
@@ -3576,7 +3723,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3576
3723
  id: toolCallId,
3577
3724
  tool: "sub_agent",
3578
3725
  agent: "writer",
3579
- label: resolveToolLabel("sub_agent", "writer"),
3726
+ label: resolveToolLabel("sub_agent", "writer", language),
3580
3727
  status: writeNeedsReview ? "error" : "completed",
3581
3728
  args: toolArgs,
3582
3729
  result: responseText,
@@ -3600,12 +3747,13 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3600
3747
  }
3601
3748
  catch (error) {
3602
3749
  const message = error instanceof Error ? error.message : String(error);
3750
+ const failure = formatAgentActionFailure(message, language);
3603
3751
  const toolResult = { content: [{ type: "text", text: message }] };
3604
3752
  const exec = {
3605
3753
  id: toolCallId,
3606
3754
  tool: "sub_agent",
3607
3755
  agent: "writer",
3608
- label: resolveToolLabel("sub_agent", "writer"),
3756
+ label: resolveToolLabel("sub_agent", "writer", language),
3609
3757
  status: "error",
3610
3758
  args: toolArgs,
3611
3759
  error: message,
@@ -3625,9 +3773,9 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3625
3773
  await refreshBookSessionFromTranscript().catch(() => undefined);
3626
3774
  broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
3627
3775
  return c.json({
3628
- error: { code: "AGENT_ACTION_FAILED", message },
3629
- response: message,
3630
- }, 502);
3776
+ error: { code: failure.code, message: failure.message },
3777
+ response: failure.message,
3778
+ }, failure.status);
3631
3779
  }
3632
3780
  }
3633
3781
  // The surface agent should speak the user's language, not just the project default.
@@ -3681,12 +3829,12 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3681
3829
  if (event.type === "tool_execution_start") {
3682
3830
  const args = event.args;
3683
3831
  const agent = event.toolName === "sub_agent" ? args?.agent : undefined;
3684
- const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
3832
+ const stages = agent ? (pipelineStages(agent, language) ?? []) : [];
3685
3833
  collectedToolExecs.push({
3686
3834
  id: event.toolCallId,
3687
3835
  tool: event.toolName,
3688
3836
  agent,
3689
- label: resolveToolLabel(event.toolName, agent),
3837
+ label: resolveToolLabel(event.toolName, agent, language),
3690
3838
  status: "running",
3691
3839
  args,
3692
3840
  stages: stages.length > 0
@@ -3759,6 +3907,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3759
3907
  agentBookId,
3760
3908
  requestedIntent,
3761
3909
  collectedToolExecs,
3910
+ language,
3762
3911
  });
3763
3912
  if (actionExecutionError) {
3764
3913
  return c.json({
@@ -3821,7 +3970,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3821
3970
  if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
3822
3971
  await finalizeCreatedBook();
3823
3972
  }
3824
- const failure = formatAgentFailure(result.errorMessage);
3973
+ const failure = formatAgentFailure(result.errorMessage, language);
3825
3974
  return c.json({
3826
3975
  error: { code: failure.code, message: failure.message },
3827
3976
  response: failure.message,
@@ -3832,6 +3981,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3832
3981
  agentBookId,
3833
3982
  requestedIntent,
3834
3983
  collectedToolExecs,
3984
+ language,
3835
3985
  });
3836
3986
  if (actionExecutionError) {
3837
3987
  return c.json({
@@ -3853,7 +4003,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3853
4003
  },
3854
4004
  });
3855
4005
  }
3856
- const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
4006
+ const emptyMessage = pick(language, "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。", "The model returned no text content. Check the protocol type (chat/responses), the streaming switch, or upstream service compatibility.");
3857
4007
  if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
3858
4008
  await finalizeCreatedBook();
3859
4009
  }
@@ -3888,11 +4038,14 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3888
4038
  // Agent busy — return 429 with user-friendly message
3889
4039
  if (/already processing|prompt.*queue/i.test(msg)) {
3890
4040
  return c.json({
3891
- error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
3892
- response: "正在处理中,请等待当前操作完成后再发送。",
4041
+ error: {
4042
+ code: "AGENT_BUSY",
4043
+ message: pick(language, "正在处理中,请等待当前操作完成", "Still processing. Wait for the current operation to finish"),
4044
+ },
4045
+ response: pick(language, "正在处理中,请等待当前操作完成后再发送。", "Still processing. Wait for the current operation to finish before sending again."),
3893
4046
  }, 429);
3894
4047
  }
3895
- const failure = formatAgentFailure(msg);
4048
+ const failure = formatAgentFailure(msg, language);
3896
4049
  return c.json({ error: { code: failure.code, message: failure.message } }, failure.status);
3897
4050
  }
3898
4051
  });
@@ -3963,6 +4116,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
3963
4116
  return c.json({ error: "Chapter not found" }, 404);
3964
4117
  const pipeline = new PipelineRunner(await buildPipelineConfig({
3965
4118
  externalContext: body.brief,
4119
+ bookIdForSettings: id,
3966
4120
  }));
3967
4121
  const normalizedMode = body.mode ?? "spot-fix";
3968
4122
  const result = await pipeline.reviseDraft(id, chapterNum, normalizedMode);
@@ -4731,6 +4885,7 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
4731
4885
  preferredStream: currentConfig.llm.stream,
4732
4886
  preferredModel: currentConfig.llm.model,
4733
4887
  proxyUrl: currentConfig.llm.proxyUrl,
4888
+ language: normalizeStudioLanguage(currentConfig.language),
4734
4889
  }), DOCTOR_LLM_PROBE_BUDGET_MS, "doctor llm probe");
4735
4890
  checks.llmConnected = probe.ok;
4736
4891
  }
@@ -4762,6 +4917,140 @@ export function createStudioServer(initialConfig, root, overrides = {}) {
4762
4917
  films.sort((a, b) => a.title.localeCompare(b.title, "zh"));
4763
4918
  return c.json({ films });
4764
4919
  });
4920
+ app.get("/api/v1/translations", async (c) => {
4921
+ const translationsDir = join(root, "translations");
4922
+ let entries = [];
4923
+ try {
4924
+ const dirents = await readdir(translationsDir, { withFileTypes: true });
4925
+ entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
4926
+ }
4927
+ catch (err) {
4928
+ if (err.code !== "ENOENT")
4929
+ throw err;
4930
+ }
4931
+ const translations = [];
4932
+ for (const projectId of entries) {
4933
+ if (!isSafeBookId(projectId))
4934
+ continue;
4935
+ try {
4936
+ const manifest = await loadTranslationManifest(root, projectId);
4937
+ translations.push({
4938
+ projectId,
4939
+ title: manifest.title,
4940
+ sourceLanguage: manifest.sourceLanguage,
4941
+ targetLanguage: manifest.targetLanguage,
4942
+ chapters: manifest.chapters.length,
4943
+ });
4944
+ }
4945
+ catch {
4946
+ // Skip dirs without a valid translation manifest.
4947
+ }
4948
+ }
4949
+ translations.sort((a, b) => b.projectId.localeCompare(a.projectId));
4950
+ return c.json({ translations });
4951
+ });
4952
+ app.post("/api/v1/translations/upload", async (c) => {
4953
+ const body = await c.req.json().catch(() => ({}));
4954
+ const result = await storeTranslationUpload(root, body);
4955
+ return c.json(result);
4956
+ });
4957
+ app.post("/api/v1/translations/create", async (c) => {
4958
+ const body = await c.req.json().catch(() => ({}));
4959
+ if (!body.filePath?.trim()) {
4960
+ return c.json({ error: { code: "MISSING_FILE_PATH", message: "filePath is required" } }, 400);
4961
+ }
4962
+ if (!body.sourceLanguage?.trim() || !body.targetLanguage?.trim()) {
4963
+ return c.json({ error: { code: "MISSING_LANGUAGES", message: "sourceLanguage and targetLanguage are required" } }, 400);
4964
+ }
4965
+ const result = await createTranslationProjectFromFile(root, {
4966
+ filePath: body.filePath,
4967
+ sourceLanguage: body.sourceLanguage,
4968
+ targetLanguage: body.targetLanguage,
4969
+ title: body.title,
4970
+ segmentMaxChars: body.segmentMaxChars,
4971
+ });
4972
+ return c.json({
4973
+ ...result,
4974
+ projectId: result.manifest.id,
4975
+ title: result.manifest.title,
4976
+ });
4977
+ });
4978
+ app.get("/api/v1/translations/:id", async (c) => {
4979
+ const id = c.req.param("id");
4980
+ if (!isSafeBookId(id)) {
4981
+ return c.json({ error: { code: "INVALID_ID", message: `invalid translation id: ${id}` } }, 400);
4982
+ }
4983
+ try {
4984
+ const manifest = await loadTranslationManifest(root, id);
4985
+ const reportPath = join(root, "translations", id, "review-report.md");
4986
+ const report = await readFile(reportPath, "utf-8").catch(() => "");
4987
+ const chapters = await Promise.all(manifest.chapters.map(async (chapter) => {
4988
+ const source = await loadTranslationChapter(root, chapter.sourcePath);
4989
+ const translated = await loadTranslationChapter(root, chapter.translatedPath).catch(() => ({
4990
+ ...source,
4991
+ segments: [],
4992
+ }));
4993
+ const targets = new Map(translated.segments.map((segment) => [segment.index, segment]));
4994
+ return {
4995
+ number: chapter.number,
4996
+ title: chapter.title,
4997
+ status: chapter.status,
4998
+ segments: source.segments.map((segment) => ({
4999
+ index: segment.index,
5000
+ source: segment.source,
5001
+ target: targets.get(segment.index)?.target ?? "",
5002
+ notes: targets.get(segment.index)?.notes ?? "",
5003
+ })),
5004
+ };
5005
+ }));
5006
+ return c.json({ manifest, report, chapters });
5007
+ }
5008
+ catch (error) {
5009
+ if (error.code === "ENOENT") {
5010
+ return c.json({ error: { code: "NOT_FOUND", message: `translation project not found for ${id}` } }, 404);
5011
+ }
5012
+ throw error;
5013
+ }
5014
+ });
5015
+ app.post("/api/v1/translations/:id/run", async (c) => {
5016
+ const id = c.req.param("id");
5017
+ if (!isSafeBookId(id)) {
5018
+ return c.json({ error: { code: "INVALID_ID", message: `invalid translation id: ${id}` } }, 400);
5019
+ }
5020
+ const body = await c.req.json().catch(() => ({}));
5021
+ try {
5022
+ const currentConfig = await loadCurrentProjectConfig();
5023
+ const model = createLLMTranslationModel({
5024
+ client: createLLMClient(currentConfig.llm),
5025
+ model: currentConfig.llm.model,
5026
+ maxTokens: body.maxTokens,
5027
+ });
5028
+ const result = await runTranslationProject(root, id, {
5029
+ model,
5030
+ batchSize: body.batchSize,
5031
+ });
5032
+ return c.json(result);
5033
+ }
5034
+ catch (error) {
5035
+ if (error instanceof ApiError)
5036
+ throw error;
5037
+ const message = error instanceof Error ? error.message : String(error);
5038
+ const isUpstream = /API|LLM|provider|upstream|temporarily unavailable|rate limit|quota|fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|503|502|504/i.test(message);
5039
+ throw new ApiError(isUpstream ? 502 : 500, "TRANSLATION_RUN_FAILED", message || "Translation run failed.");
5040
+ }
5041
+ });
5042
+ app.post("/api/v1/translations/:id/export", async (c) => {
5043
+ const id = c.req.param("id");
5044
+ if (!isSafeBookId(id)) {
5045
+ return c.json({ error: { code: "INVALID_ID", message: `invalid translation id: ${id}` } }, 400);
5046
+ }
5047
+ const body = await c.req.json().catch(() => ({}));
5048
+ const result = await writeTranslationExport(root, id, {
5049
+ format: body.format ?? "md",
5050
+ outputPath: body.outputPath,
5051
+ });
5052
+ return c.json(result);
5053
+ });
4765
5054
  app.post("/api/v1/projects/:id/story-graph/delta", async (c) => {
4766
5055
  const id = c.req.param("id");
4767
5056
  if (!isSafeBookId(id)) {