@actalk/inkos-studio 1.4.1 → 1.5.0-canary.47.1

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 (71) hide show
  1. package/dist/api/book-create.d.ts +2 -15
  2. package/dist/api/book-create.d.ts.map +1 -1
  3. package/dist/api/book-create.js +3 -23
  4. package/dist/api/book-create.js.map +1 -1
  5. package/dist/api/server.d.ts.map +1 -1
  6. package/dist/api/server.js +1106 -171
  7. package/dist/api/server.js.map +1 -1
  8. package/dist/assets/{_baseUniq-DuJFcOdr.js → _baseUniq-DBJGQpfz.js} +1 -1
  9. package/dist/assets/{arc-CRu6VUhj.js → arc-DLB2F6Ls.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-CIqV4qz7.js → architectureDiagram-Q4EWVU46-BFuSKPVt.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-fTi_ECOn.js → blockDiagram-DXYQGD6D-CfyajMt_.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-Bzk5MYNH.js → c4Diagram-AHTNJAMY-C39iCuyt.js} +1 -1
  13. package/dist/assets/channel-DU6XMRUx.js +1 -0
  14. package/dist/assets/{chunk-4BX2VUAB-HjzLE1LZ.js → chunk-4BX2VUAB-CjszI3xK.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-BJyVLr8h.js → chunk-4TB4RGXK-_Pw5P4zU.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-CFMufWYu.js → chunk-55IACEB6-CyhEfvSu.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-BsY-2qu7.js → chunk-EDXVE4YY-CRB16gOE.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-CIhpQnBe.js → chunk-FMBD7UC4-CQNtSClR.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-CIZ6okx7.js → chunk-OYMX7WX6-CrIyKPh2.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Dkcne4nr.js → chunk-QZHKN3VN-Dp8aCjyj.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-DQuvXxIP.js → chunk-YZCP3GAM-BSoI3fCo.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-DDpzyIVF.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-DDpzyIVF.js +1 -0
  24. package/dist/assets/clone-CJju3aYb.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-CT-CUv0U.js → cose-bilkent-S5V4N54A-ib3OuAHZ.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-DRS18Avn.js → dagre-KV5264BT-Cgoc5396.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-CI3ev01f.js → diagram-5BDNPKRD-zbMY2ZKa.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-B1QvOXJt.js → diagram-G4DWMVQ6-CQ-7PjEw.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-DJIeBvHf.js → diagram-MMDJMWI5-Ckk-FPgp.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-DKSFwm0R.js → diagram-TYMM5635-BHSvAn1k.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-D0i9HWsj.js → erDiagram-SMLLAGMA-CIYuDmiL.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Dq4sOV0a.js → flowDiagram-DWJPFMVM-CK59h9F2.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-FVlGz1Cc.js → ganttDiagram-T4ZO3ILL-Bdt7WCSI.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-jVSM4fIZ.js → gitGraphDiagram-UUTBAWPF-BHi_pqWs.js} +1 -1
  35. package/dist/assets/{graph-BwgR_wTK.js → graph-Bf6kO5hN.js} +1 -1
  36. package/dist/assets/{highlighted-body-OFNGDK62-ht7p7AB1.js → highlighted-body-OFNGDK62-ByVUxUAP.js} +1 -1
  37. package/dist/assets/index-CEMuJFJQ.js +1366 -0
  38. package/dist/assets/index-Dfjchve7.css +1 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-D4nEy-tx.js → infoDiagram-42DDH7IO-OZSf158n.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BY35FcPr.js → ishikawaDiagram-UXIWVN3A-DOXrXOVm.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-C2PT0pXA.js → journeyDiagram-VCZTEJTY-CI_P_1qn.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DD0dYiJ-.js → kanban-definition-6JOO6SKY-CbVduhit.js} +1 -1
  43. package/dist/assets/{layout-sC_Hhgm4.js → layout-CDYPyKY3.js} +1 -1
  44. package/dist/assets/{linear-0i0GyJQs.js → linear-Dh9hHPIC.js} +1 -1
  45. package/dist/assets/{min-DOL6Y_RU.js → min-BzBfMBhf.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-MEQTU3lB.js → mindmap-definition-QFDTVHPH-CeKF_r9_.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-eg09tT0v.js → pieDiagram-DEJITSTG-xuXMIHlg.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-Dbp-ydjf.js → quadrantDiagram-34T5L4WZ-bTuh0Bqf.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-Bxfwqnat.js → requirementDiagram-MS252O5E-FG5TUOrq.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-BcZnxYY3.js → sankeyDiagram-XADWPNL6-C0OOFrN9.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-poDxcRyl.js → sequenceDiagram-FGHM5R23-Ckpth_cI.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-yNt0zNcA.js → stateDiagram-FHFEXIEX-D8RrbVIs.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-OK_IuyjR.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-C7rYgB33.js → timeline-definition-GMOUNBTQ-uuHijcIj.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BWbCUy6T.js → vennDiagram-DHZGUBPP-CsyOphhl.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-CR0zTOY_.js → wardley-RL74JXVD-2nbtdaa2.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-zhLoptAy.js → wardleyDiagram-NUSXRM2D-CF4OidJg.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-N0gnUPD3.js → xychartDiagram-5P7HB3ND-S50l-zj6.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist/lib/book-ready.d.ts +15 -0
  61. package/dist/lib/book-ready.d.ts.map +1 -0
  62. package/dist/lib/book-ready.js +37 -0
  63. package/dist/lib/book-ready.js.map +1 -0
  64. package/package.json +2 -2
  65. package/dist/assets/channel-uCIAc1hE.js +0 -1
  66. package/dist/assets/classDiagram-6PBFFD2Q-B8poauXH.js +0 -1
  67. package/dist/assets/classDiagram-v2-HSJHXN6E-B8poauXH.js +0 -1
  68. package/dist/assets/clone-CXmG6BfO.js +0 -1
  69. package/dist/assets/index-BWQL1zpM.css +0 -1
  70. package/dist/assets/index-COgOkP4B.js +0 -1309
  71. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CVrFbbZA.js +0 -1
@@ -2,7 +2,7 @@ import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import { streamSSE } from "hono/streaming";
4
4
  import { serve } from "@hono/node-server";
5
- import { StateManager, PipelineRunner, createLLMClient, createLogger, createInteractionToolsFromDeps, computeAnalytics, loadProjectConfig, loadProjectSession, processProjectInteractionRequest, resolveSessionActiveBook, listBookSessions, loadBookSession, appendManualSessionMessages, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, runAgentSession, buildAgentSystemPrompt, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, listModelsForService, isApiKeyOptionalForEndpoint, getAllEndpoints, probeModelsFromUpstream, fetchWithProxy, chatCompletion, buildExportArtifact, GLOBAL_ENV_PATH, COVER_PROVIDER_PRESETS, Scheduler, coverSecretKey, resolveCoverProviderPreset, } from "@actalk/inkos-core";
5
+ import { StateManager, PipelineRunner, createLLMClient, createLogger, createInteractionToolsFromDeps, computeAnalytics, loadProjectConfig, loadProjectSession, processProjectInteractionRequest, resolveSessionActiveBook, listBookSessions, loadBookSession, appendManualSessionMessages, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, runAgentSession, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, guessServiceFromBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, listModelsForService, isApiKeyOptionalForEndpoint, getAllEndpoints, probeModelsFromUpstream, fetchWithProxy, chatCompletion, buildExportArtifact, evaluateBookQuality, ConsolidatorAgent, DetectionConfigSchema, 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, inferLanguage, createGenerateCoverTool, createPlayStartTool, createShortFictionRunTool, createSubAgentTool, } from "@actalk/inkos-core";
6
6
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
7
7
  import { isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { isSafeBookId } from "./safety.js";
@@ -31,8 +31,13 @@ const AGENT_LABELS = {
31
31
  };
32
32
  const TOOL_LABELS = {
33
33
  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
34
+ propose_action: "确认动作",
34
35
  short_fiction_run: "短篇生产",
35
36
  generate_cover: "生成封面",
37
+ play_edit: "编辑互动世界",
38
+ play_start: "启动互动世界",
39
+ play_revise: "重做互动回合",
40
+ play_step: "推进互动世界",
36
41
  };
37
42
  function resolveToolLabel(tool, agent) {
38
43
  if (tool === "sub_agent" && agent)
@@ -77,6 +82,9 @@ const NON_TEXT_MODEL_ID_PARTS = [
77
82
  ];
78
83
  const SERVICE_MODELS_PROBE_TIMEOUT_MS = 4_000;
79
84
  const SERVICE_CHAT_PROBE_TIMEOUT_MS = 8_000;
85
+ // Hard ceiling for the whole /doctor connectivity probe (models + chat fallback
86
+ // loop) so the diagnostics page never spins on a slow/rate-limited upstream.
87
+ const DOCTOR_LLM_PROBE_BUDGET_MS = 9_000;
80
88
  const MAX_DISCOVERED_MODELS_TO_PING = 2;
81
89
  const MAX_GENERIC_FALLBACK_MODELS_TO_PING = 2;
82
90
  function isTextChatModelId(modelId) {
@@ -168,10 +176,64 @@ function hasSuccessfulSubAgentExec(execs, agent) {
168
176
  && exec.status === "completed"
169
177
  && !isLikelyFailedToolResult(exec));
170
178
  }
171
- function isWriteNextInstruction(instruction) {
172
- const trimmed = instruction.trim();
173
- return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
174
- || /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
179
+ function hasSuccessfulToolExec(execs, tool) {
180
+ return execs.some((exec) => exec.tool === tool
181
+ && exec.status === "completed"
182
+ && !isLikelyFailedToolResult(exec));
183
+ }
184
+ function hasSuccessfulToolResult(execs) {
185
+ return execs.some((exec) => exec.status === "completed" && !isLikelyFailedToolResult(exec));
186
+ }
187
+ function normalizeStudioSessionKind(value, fallback) {
188
+ if (value === undefined || value === null || value === "")
189
+ return fallback;
190
+ const parsed = SessionKindSchema.safeParse(value);
191
+ if (!parsed.success) {
192
+ throw new ApiError(400, "INVALID_SESSION_KIND", `Invalid sessionKind: ${String(value)}`);
193
+ }
194
+ return parsed.data;
195
+ }
196
+ function normalizeStudioActionSource(value) {
197
+ try {
198
+ return normalizeCoreActionSource(value);
199
+ }
200
+ catch {
201
+ throw new ApiError(400, "INVALID_ACTION_SOURCE", `Invalid actionSource: ${String(value)}`);
202
+ }
203
+ }
204
+ function normalizeStudioRequestedIntent(value) {
205
+ try {
206
+ return normalizeCoreRequestedIntent(value);
207
+ }
208
+ catch {
209
+ throw new ApiError(400, "INVALID_REQUESTED_INTENT", `Invalid requestedIntent: ${String(value)}`);
210
+ }
211
+ }
212
+ function normalizeStudioActionPayload(value) {
213
+ try {
214
+ return normalizeCoreActionPayload(value);
215
+ }
216
+ catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ throw new ApiError(400, "INVALID_ACTION_PAYLOAD", `Invalid actionPayload: ${message}`);
219
+ }
220
+ }
221
+ function normalizeStudioPlayMode(value) {
222
+ try {
223
+ return normalizeCorePlayMode(value);
224
+ }
225
+ catch {
226
+ throw new ApiError(400, "INVALID_PLAY_MODE", `Invalid playMode: ${String(value)}`);
227
+ }
228
+ }
229
+ function shouldRunDirectWriteNext(args) {
230
+ if (!args.agentBookId || args.sessionKind !== "book")
231
+ return false;
232
+ if (args.requestedIntent === "write_next")
233
+ return true;
234
+ if (args.actionSource === "free-text")
235
+ return isExplicitWriteChapterCommand(args.instruction);
236
+ return isWriteNextInstruction(args.instruction);
175
237
  }
176
238
  const CHAT_EDIT_WARNING = "[warning] Chat external edit requires review before continuation.";
177
239
  const CHAT_EDIT_TEXT_EXTENSIONS = /\.(md|txt|json|ya?ml)$/i;
@@ -191,6 +253,18 @@ function parseReplacementInstruction(instruction) {
191
253
  }
192
254
  return null;
193
255
  }
256
+ function isExplicitExternalChatEditInstruction(instruction) {
257
+ const trimmed = instruction.trim();
258
+ if (!trimmed)
259
+ return false;
260
+ if (/[??]\s*$/.test(trimmed))
261
+ return false;
262
+ if (/^(?:请问|能否|能不能|可以|可不可以|是否|是不是|怎么|怎样|为什么|如果|假如|要不要|建议|讨论)\b/u.test(trimmed)) {
263
+ return false;
264
+ }
265
+ const imperative = trimmed.replace(/^(?:请|麻烦|帮我|直接|现在)\s*/u, "");
266
+ return /^(?:第\s*\d{1,4}\s*章\s*)?(?:把|将)\s*/u.test(imperative);
267
+ }
194
268
  function parseChapterNumberForEdit(instruction) {
195
269
  const match = instruction.match(/第\s*(\d{1,4})\s*章/);
196
270
  if (!match?.[1])
@@ -278,6 +352,8 @@ async function tryHandleExternalChatEdit(params) {
278
352
  const replacement = parseReplacementInstruction(params.instruction);
279
353
  if (!replacement)
280
354
  return null;
355
+ if (!isExplicitExternalChatEditInstruction(params.instruction))
356
+ return null;
281
357
  const explicitPath = parseExplicitEditPath(params.instruction);
282
358
  if (explicitPath) {
283
359
  const target = resolveExternalChatEditPath(params.root, explicitPath);
@@ -342,27 +418,247 @@ async function tryHandleExternalChatEdit(params) {
342
418
  responseText: `已直接编辑 ${params.activeBookId} 第 ${chapterNumber} 章,并标记为需要复核。`,
343
419
  };
344
420
  }
345
- function looksLikeBookCreatedClaim(responseText) {
346
- return /(?:已|已经|成功).{0,12}(?:创建|建书|初始化|保存).{0,12}(?:作品|书|书籍|文件夹)?/.test(responseText)
347
- || /\b(?:created|initiali[sz]ed|saved)\b.{0,40}\b(?:book|project|novel)\b/i.test(responseText);
348
- }
349
421
  function validateAgentActionExecution(args) {
350
422
  const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
351
423
  if (failedExec) {
352
424
  return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
353
425
  }
354
426
  if (args.agentBookId
355
- && isWriteNextInstruction(args.instruction)
427
+ && args.requestedIntent === "write_next"
356
428
  && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")) {
357
429
  return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
358
430
  }
359
431
  if (!args.agentBookId
360
- && looksLikeBookCreatedClaim(args.responseText)
361
- && !resolveCreatedBookIdFromToolExecs(args.collectedToolExecs)) {
362
- return "模型声称已创建作品,但没有实际调用建书工具,也没有生成作品文件。请补充书名/题材后重试,或换用支持工具调用的模型。";
432
+ && args.requestedIntent === "create_book"
433
+ && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "architect")) {
434
+ return "已确认建书,但模型没有实际调用建书工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
435
+ }
436
+ if (args.requestedIntent === "short_run" && !hasSuccessfulToolExec(args.collectedToolExecs, "short_fiction_run")) {
437
+ return "已确认生成短篇,但模型没有实际调用短篇生产工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
438
+ }
439
+ if (args.requestedIntent === "play_start" && !hasSuccessfulToolExec(args.collectedToolExecs, "play_start")) {
440
+ return "已确认启动互动世界,但模型没有实际调用互动世界工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
441
+ }
442
+ if (args.requestedIntent === "generate_cover" && !hasSuccessfulToolExec(args.collectedToolExecs, "generate_cover")) {
443
+ return "已确认生成封面,但模型没有实际调用封面工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
363
444
  }
364
445
  return undefined;
365
446
  }
447
+ function classifyAgentFailure(message) {
448
+ const text = message.trim();
449
+ if (!text)
450
+ return "unknown";
451
+ 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)) {
452
+ return "llm";
453
+ }
454
+ if (/PlannerParseError|Architect output missing|required sections|missing YAML frontmatter|frontmatter delimiters|parseMemo|Book creation artifact is incomplete|Short-hit draft is incomplete|工具执行失败|执行失败|sub_agent|tool execution|RUNTIME_STATE_DELTA|JSON parse|解析失败/i.test(text)) {
455
+ return "internal";
456
+ }
457
+ return "unknown";
458
+ }
459
+ function formatAgentFailure(message) {
460
+ const kind = classifyAgentFailure(message);
461
+ if (kind === "llm") {
462
+ return { code: "AGENT_LLM_ERROR", message, status: 502 };
463
+ }
464
+ if (kind === "internal") {
465
+ return { code: "AGENT_INTERNAL_ERROR", message: `InkOS 内部流程错误:${message}`, status: 500 };
466
+ }
467
+ return { code: "AGENT_ERROR", message, status: 500 };
468
+ }
469
+ class ConfirmedActionExecutionError extends Error {
470
+ exec;
471
+ constructor(message, exec, cause) {
472
+ super(message);
473
+ this.name = "ConfirmedActionExecutionError";
474
+ this.exec = exec;
475
+ if (cause !== undefined) {
476
+ this.cause = cause;
477
+ }
478
+ }
479
+ }
480
+ function suppressManualTextForTool(exec) {
481
+ return exec.tool === "play_start" || exec.tool === "play_step" || exec.tool === "play_revise";
482
+ }
483
+ function manualToolAssistantMessage(responseText, exec, provider, model) {
484
+ return {
485
+ role: "assistant",
486
+ content: [{ type: "text", text: suppressManualTextForTool(exec) ? "" : responseText }],
487
+ api: "anthropic-messages",
488
+ provider,
489
+ model,
490
+ usage: {
491
+ input: 0,
492
+ output: 0,
493
+ cacheRead: 0,
494
+ cacheWrite: 0,
495
+ totalTokens: 0,
496
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
497
+ },
498
+ stopReason: "toolUse",
499
+ timestamp: Date.now(),
500
+ };
501
+ }
502
+ function manualToolAppendOptions(sessionKind, exec) {
503
+ return {
504
+ sessionKind,
505
+ legacyDisplay: { toolExecutions: [exec] },
506
+ };
507
+ }
508
+ function isConfirmedProductionAction(args) {
509
+ return (args.actionSource === "button" || args.actionSource === "slash")
510
+ && (args.requestedIntent === "create_book"
511
+ || args.requestedIntent === "short_run"
512
+ || args.requestedIntent === "play_start"
513
+ || args.requestedIntent === "generate_cover");
514
+ }
515
+ function requirePayloadText(value, message) {
516
+ const text = value?.trim();
517
+ if (!text) {
518
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", message);
519
+ }
520
+ return text;
521
+ }
522
+ function toolResultText(result) {
523
+ const text = extractToolError(result).trim();
524
+ return text || "已完成。";
525
+ }
526
+ async function executeConfirmedProductionAction(args) {
527
+ const id = `direct-${args.requestedIntent}-${Date.now().toString(36)}`;
528
+ const actionPayload = args.actionPayload;
529
+ let tool;
530
+ let params;
531
+ let agent;
532
+ if (args.requestedIntent === "create_book") {
533
+ const payload = actionPayload?.createBook;
534
+ const title = requirePayloadText(payload?.title, "确认建书缺少书名,请重新生成确认卡。");
535
+ tool = createSubAgentTool(args.pipeline, null, args.root, { actionPayload });
536
+ agent = "architect";
537
+ params = {
538
+ agent,
539
+ instruction: args.instruction,
540
+ title,
541
+ ...(payload?.genre ? { genre: payload.genre } : {}),
542
+ ...(payload?.platform ? { platform: payload.platform } : {}),
543
+ ...(payload?.language ? { language: payload.language } : {}),
544
+ ...(payload?.targetChapters ? { targetChapters: payload.targetChapters } : {}),
545
+ ...(payload?.chapterWordCount ? { chapterWordCount: payload.chapterWordCount } : {}),
546
+ };
547
+ }
548
+ else if (args.requestedIntent === "short_run") {
549
+ const payload = actionPayload?.shortRun;
550
+ const direction = payload?.direction?.trim() || args.instruction.trim();
551
+ if (!direction)
552
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认短篇缺少方向,请重新生成确认卡。");
553
+ tool = createShortFictionRunTool(args.pipeline, args.root, { actionPayload });
554
+ params = {
555
+ direction,
556
+ ...(payload?.reference ? { reference: payload.reference } : {}),
557
+ ...(payload?.storyId ? { storyId: payload.storyId } : {}),
558
+ ...(payload?.chapters ? { chapters: payload.chapters } : {}),
559
+ ...(payload?.charsPerChapter ? { charsPerChapter: payload.charsPerChapter } : {}),
560
+ ...(payload?.cover !== undefined ? { cover: payload.cover } : {}),
561
+ };
562
+ }
563
+ else if (args.requestedIntent === "generate_cover") {
564
+ const payload = actionPayload?.generateCover;
565
+ const title = requirePayloadText(payload?.title, "确认生成封面缺少标题,请重新生成确认卡。");
566
+ tool = createGenerateCoverTool(args.root, { actionPayload });
567
+ params = {
568
+ title,
569
+ ...(payload?.intro ? { intro: payload.intro } : {}),
570
+ ...(payload?.sellingPoints ? { sellingPoints: payload.sellingPoints } : {}),
571
+ ...(payload?.coverPrompt ? { coverPrompt: payload.coverPrompt } : {}),
572
+ ...(payload?.outputDir ? { outputDir: payload.outputDir } : {}),
573
+ };
574
+ }
575
+ else if (args.requestedIntent === "play_start") {
576
+ const payload = actionPayload?.playStart;
577
+ const title = requirePayloadText(payload?.title, "确认启动互动世界缺少标题,请重新生成确认卡。");
578
+ const fallbackScene = [payload?.premise, args.instruction].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n\n");
579
+ const initialScene = isUsablePlayInitialScene(payload?.initialScene)
580
+ ? payload?.initialScene?.trim()
581
+ : fallbackScene.trim();
582
+ const confirmedActionPayload = actionPayload
583
+ ? {
584
+ ...actionPayload,
585
+ playStart: {
586
+ ...payload,
587
+ title,
588
+ ...(initialScene ? { initialScene } : {}),
589
+ },
590
+ }
591
+ : undefined;
592
+ tool = createPlayStartTool(args.pipeline, args.root, args.sessionId, args.playMode, { actionPayload: confirmedActionPayload });
593
+ params = {
594
+ title,
595
+ ...(payload?.premise ? { premise: payload.premise } : {}),
596
+ ...(payload?.worldContract ? { worldContract: payload.worldContract } : {}),
597
+ ...(payload?.visualContract ? { visualContract: payload.visualContract } : {}),
598
+ ...(payload?.mode ? { mode: payload.mode } : {}),
599
+ ...(initialScene ? { initialScene } : {}),
600
+ ...(payload?.suggestedActions ? { suggestedActions: payload.suggestedActions } : {}),
601
+ };
602
+ }
603
+ else {
604
+ throw new ApiError(400, "UNSUPPORTED_CONFIRMED_ACTION", `Unsupported confirmed action: ${args.requestedIntent}`);
605
+ }
606
+ const exec = {
607
+ id,
608
+ tool: tool.name,
609
+ agent,
610
+ label: resolveToolLabel(tool.name, agent),
611
+ status: "running",
612
+ args: params,
613
+ stages: agent ? PIPELINE_STAGES[agent]?.map(label => ({ label, status: "pending" })) : undefined,
614
+ startedAt: Date.now(),
615
+ };
616
+ broadcast("tool:start", {
617
+ sessionId: args.streamSessionId,
618
+ id,
619
+ tool: tool.name,
620
+ args: params,
621
+ stages: exec.stages?.map(stage => stage.label),
622
+ });
623
+ try {
624
+ const result = await tool.execute(id, params, undefined, (partialResult) => {
625
+ broadcast("tool:update", {
626
+ sessionId: args.streamSessionId,
627
+ tool: tool.name,
628
+ partialResult,
629
+ });
630
+ });
631
+ exec.status = "completed";
632
+ exec.completedAt = Date.now();
633
+ exec.result = toolResultText(result);
634
+ exec.details = result?.details;
635
+ exec.stages = exec.stages?.map(stage => ({ ...stage, status: "completed" }));
636
+ broadcast("tool:end", {
637
+ sessionId: args.streamSessionId,
638
+ id,
639
+ tool: tool.name,
640
+ result,
641
+ details: exec.details,
642
+ isError: false,
643
+ });
644
+ return exec;
645
+ }
646
+ catch (error) {
647
+ const message = error instanceof Error ? error.message : String(error);
648
+ const result = { content: [{ type: "text", text: message }] };
649
+ exec.status = "error";
650
+ exec.completedAt = Date.now();
651
+ exec.error = message;
652
+ broadcast("tool:end", {
653
+ sessionId: args.streamSessionId,
654
+ id,
655
+ tool: tool.name,
656
+ result,
657
+ isError: true,
658
+ });
659
+ throw new ConfirmedActionExecutionError(message, exec, error);
660
+ }
661
+ }
366
662
  const subscribers = new Set();
367
663
  const bookCreateStatus = new Map();
368
664
  // 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
@@ -381,6 +677,16 @@ function deriveBookIdFromTitle(title) {
381
677
  .replace(/^-+|-+$/g, "")
382
678
  .slice(0, 30);
383
679
  }
680
+ async function completeBookExists(bookDir) {
681
+ try {
682
+ await access(join(bookDir, "book.json"));
683
+ await access(join(bookDir, "story", "story_bible.md"));
684
+ return true;
685
+ }
686
+ catch {
687
+ return false;
688
+ }
689
+ }
384
690
  function resolveArchitectBookIdFromArgs(args) {
385
691
  if (!args || args.agent !== "architect" || args.revise === true)
386
692
  return null;
@@ -400,9 +706,12 @@ function resolveCreatedBookIdFromToolExecs(execs) {
400
706
  if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
401
707
  return details.bookId.trim();
402
708
  }
403
- const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
404
- if (fromArgs)
405
- return fromArgs;
709
+ }
710
+ return null;
711
+ }
712
+ function resolveCreatedBookIdFromDetails(details) {
713
+ if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
714
+ return details.bookId.trim();
406
715
  }
407
716
  return null;
408
717
  }
@@ -519,7 +828,24 @@ async function loadRawConfig(root) {
519
828
  async function saveRawConfig(root, config) {
520
829
  await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
521
830
  }
522
- async function readEnvConfigSummary(path) {
831
+ function unquoteEnvValue(value) {
832
+ const trimmed = value.trim();
833
+ if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
834
+ return trimmed.slice(1, -1);
835
+ }
836
+ return trimmed;
837
+ }
838
+ function toEnvConfigSummary(values) {
839
+ return {
840
+ detected: values.detected,
841
+ provider: values.provider,
842
+ service: values.service ?? null,
843
+ baseUrl: values.baseUrl,
844
+ model: values.model,
845
+ hasApiKey: values.hasApiKey,
846
+ };
847
+ }
848
+ async function readEnvConfigValues(path) {
523
849
  try {
524
850
  const raw = await readFile(path, "utf-8");
525
851
  const values = new Map();
@@ -531,41 +857,55 @@ async function readEnvConfigSummary(path) {
531
857
  if (!match)
532
858
  continue;
533
859
  const [, key, value] = match;
534
- values.set(key, value.trim());
860
+ values.set(key, unquoteEnvValue(value));
535
861
  }
536
862
  const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
863
+ const service = values.get("INKOS_LLM_SERVICE") ?? null;
537
864
  const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
538
865
  const model = values.get("INKOS_LLM_MODEL") ?? null;
539
866
  const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
540
- const detected = Boolean(provider || baseUrl || model || apiKey);
867
+ const detected = Boolean(provider || service || baseUrl || model || apiKey);
541
868
  return {
542
869
  detected,
543
870
  provider,
871
+ service,
544
872
  baseUrl,
545
873
  model,
546
874
  hasApiKey: apiKey.length > 0,
875
+ apiKey: apiKey.length > 0 ? apiKey : null,
547
876
  };
548
877
  }
549
878
  catch {
550
879
  return {
551
880
  detected: false,
552
881
  provider: null,
882
+ service: null,
553
883
  baseUrl: null,
554
884
  model: null,
555
885
  hasApiKey: false,
886
+ apiKey: null,
556
887
  };
557
888
  }
558
889
  }
559
890
  async function readEnvConfigStatus(root) {
560
- const project = await readEnvConfigSummary(join(root, ".env"));
561
- const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
891
+ const project = await readEnvConfigValues(join(root, ".env"));
892
+ const global = await readEnvConfigValues(GLOBAL_ENV_PATH);
562
893
  return {
563
- project,
564
- global,
894
+ project: toEnvConfigSummary(project),
895
+ global: toEnvConfigSummary(global),
565
896
  effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
566
897
  runtimeUsesEnv: false,
567
898
  };
568
899
  }
900
+ async function readEffectiveEnvConfigValues(root) {
901
+ const project = await readEnvConfigValues(join(root, ".env"));
902
+ if (project.detected)
903
+ return { source: "project", values: project };
904
+ const global = await readEnvConfigValues(GLOBAL_ENV_PATH);
905
+ if (global.detected)
906
+ return { source: "global", values: global };
907
+ return null;
908
+ }
569
909
  async function resolveConfiguredServiceBaseUrl(root, serviceId, inlineBaseUrl) {
570
910
  if (inlineBaseUrl?.trim())
571
911
  return inlineBaseUrl.trim();
@@ -917,7 +1257,11 @@ async function probeServiceCapabilities(args) {
917
1257
  stream: plan.stream,
918
1258
  });
919
1259
  try {
920
- await withTimeout(chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16 }), SERVICE_CHAT_PROBE_TIMEOUT_MS, "service connection test");
1260
+ await withTimeout(
1261
+ // A connectivity probe wants a fast pass/fail — never the transient
1262
+ // retry+backoff, which would multiply the time when the upstream is
1263
+ // rate-limiting (and make the diagnostics page hang).
1264
+ chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16, retry: false }), SERVICE_CHAT_PROBE_TIMEOUT_MS, "service connection test");
921
1265
  const models = discoveredModels.length > 0
922
1266
  ? discoveredModels
923
1267
  : fallbackTextModelsForEndpoint(endpoint, preset);
@@ -1028,9 +1372,16 @@ export function createStudioServer(initialConfig, root) {
1028
1372
  defaultLLMConfig: currentConfig.llm,
1029
1373
  foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
1030
1374
  writingReviewRetries: currentConfig.writing?.reviewRetries ?? 1,
1375
+ chapterReviewMode: currentConfig.writing?.reviewMode === "manual" ? "manual" : "auto",
1031
1376
  modelOverrides: currentConfig.modelOverrides,
1032
1377
  notifyChannels: currentConfig.notify,
1033
1378
  logger,
1379
+ onContextCompression: (event) => {
1380
+ broadcast("context:compression", {
1381
+ ...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
1382
+ ...event,
1383
+ });
1384
+ },
1034
1385
  onStreamProgress: (progress) => {
1035
1386
  broadcast("llm:progress", {
1036
1387
  ...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
@@ -1083,13 +1434,11 @@ export function createStudioServer(initialConfig, root) {
1083
1434
  const bookConfig = buildStudioBookConfig(body, now);
1084
1435
  const bookId = bookConfig.id;
1085
1436
  const bookDir = state.bookDir(bookId);
1086
- try {
1087
- await access(join(bookDir, "book.json"));
1088
- await access(join(bookDir, "story", "story_bible.md"));
1089
- return c.json({ error: `Book "${bookId}" already exists` }, 409);
1437
+ if (!bookId) {
1438
+ return c.json({ error: "Could not derive a valid book id from title" }, 400);
1090
1439
  }
1091
- catch {
1092
- // The target book is not fully initialized yet, so creation can continue.
1440
+ if (await completeBookExists(bookDir)) {
1441
+ return c.json({ error: `Book "${bookId}" already exists` }, 409);
1093
1442
  }
1094
1443
  broadcast("book:creating", { bookId, title: body.title });
1095
1444
  bookCreateStatus.set(bookId, { status: "creating" });
@@ -1109,7 +1458,19 @@ export function createStudioServer(initialConfig, root) {
1109
1458
  },
1110
1459
  tools,
1111
1460
  }).then(async (result) => {
1112
- const createdBookId = result.details?.bookId ?? result.session.activeBookId ?? bookId;
1461
+ const createdBookId = resolveCreatedBookIdFromDetails(result.details);
1462
+ if (!createdBookId) {
1463
+ const error = "Book creation did not produce a completed book artifact.";
1464
+ bookCreateStatus.set(bookId, { status: "error", error });
1465
+ broadcast("book:error", { bookId, error });
1466
+ return;
1467
+ }
1468
+ if (!await completeBookExists(join(root, "books", createdBookId))) {
1469
+ const error = "Book creation artifact is incomplete on disk.";
1470
+ bookCreateStatus.set(createdBookId, { status: "error", error });
1471
+ broadcast("book:error", { bookId: createdBookId, error });
1472
+ return;
1473
+ }
1113
1474
  const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
1114
1475
  bookCreateStatus.delete(createdBookId);
1115
1476
  broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
@@ -1123,10 +1484,18 @@ export function createStudioServer(initialConfig, root) {
1123
1484
  app.get("/api/v1/books/:id/create-status", async (c) => {
1124
1485
  const id = c.req.param("id");
1125
1486
  const status = bookCreateStatus.get(id);
1126
- if (!status) {
1127
- return c.json({ status: "missing" }, 404);
1487
+ if (status) {
1488
+ return c.json(status);
1128
1489
  }
1129
- return c.json(status);
1490
+ // No in-memory entry. On success the entry is deleted, and a long architect
1491
+ // run (or a server restart) can also drop it — so a bare 404 is ambiguous
1492
+ // ("done" vs "never existed"). Check disk: if the foundation is fully
1493
+ // written, the book really is ready; report that truthfully.
1494
+ const { isBookFoundationComplete } = await import("@actalk/inkos-core");
1495
+ if (await isBookFoundationComplete(state.bookDir(id))) {
1496
+ return c.json({ status: "ready" });
1497
+ }
1498
+ return c.json({ status: "missing" }, 404);
1130
1499
  });
1131
1500
  // --- Chapters ---
1132
1501
  app.get("/api/v1/books/:id/chapters/:num", async (c) => {
@@ -1199,10 +1568,12 @@ export function createStudioServer(initialConfig, root) {
1199
1568
  // GET handler tags them with `legacy: true` so the UI can surface that the
1200
1569
  // edits won't land where the user expects.
1201
1570
  const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
1571
+ const RUNTIME_DIAGNOSTIC_FILE_RE = /^runtime\/chapter-\d{4}\.(?:intent\.md|plan\.md|context\.json|rule-stack\.yaml|trace\.json)$/;
1202
1572
  /**
1203
1573
  * Validate a requested truth-file path:
1204
1574
  * 1. Must be one of the declared flat files, an outline/* allow-listed
1205
- * entry, or a roles/**\/*.md file under 主要角色/ | 次要角色/.
1575
+ * entry, a runtime chapter trace file, or a roles/**\/*.md file under
1576
+ * 主要角色/ | 次要角色/.
1206
1577
  * 2. Must resolve to a path inside bookDir/story/ (no `..`, no absolute
1207
1578
  * paths, no traversal via the tier-name segment).
1208
1579
  */
@@ -1217,6 +1588,7 @@ export function createStudioServer(initialConfig, root) {
1217
1588
  // both — Studio used to drop English books to read-only.
1218
1589
  const allowed = TRUTH_FLAT_FILES.includes(file)
1219
1590
  || TRUTH_OUTLINE_FILES.includes(file)
1591
+ || RUNTIME_DIAGNOSTIC_FILE_RE.test(file)
1220
1592
  || /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/.test(file);
1221
1593
  if (!allowed)
1222
1594
  return null;
@@ -1252,14 +1624,34 @@ export function createStudioServer(initialConfig, root) {
1252
1624
  // can warn users their edits won't reach the runtime.
1253
1625
  // Hotfix: only tag as legacy when the book actually HAS the new layout.
1254
1626
  // Pre-Phase-5 books use story_bible/book_rules as the authoritative source.
1255
- const { isNewLayoutBook } = await import("@actalk/inkos-core");
1627
+ const { isNewLayoutBook, tryParseBookRulesFrontmatter } = await import("@actalk/inkos-core");
1256
1628
  const legacy = LEGACY_SHIM_FILES.has(file) && await isNewLayoutBook(bookDir);
1257
1629
  try {
1258
1630
  const content = await readFile(resolved, "utf-8");
1259
- return c.json({ file, content, ...(legacy ? { legacy: true } : {}) });
1631
+ // Files like outline/story_frame.md carry a YAML frontmatter block of
1632
+ // structured fields (protagonist / genreLock / prohibitions / ...). Parse
1633
+ // it here so the UI can render those as friendly cards instead of dumping
1634
+ // raw YAML at the reader. `content` stays raw so the editor round-trips it
1635
+ // unchanged; `body` is the prose with the frontmatter stripped.
1636
+ const parsed = tryParseBookRulesFrontmatter(content);
1637
+ const structured = parsed ? { frontmatter: parsed.rules, body: parsed.body } : {};
1638
+ const runtimeDiagnostic = RUNTIME_DIAGNOSTIC_FILE_RE.test(file);
1639
+ return c.json({
1640
+ file,
1641
+ content,
1642
+ ...structured,
1643
+ ...(legacy ? { legacy: true } : {}),
1644
+ ...(runtimeDiagnostic ? { readonly: true, readonlyReason: "runtime-diagnostic" } : {}),
1645
+ });
1260
1646
  }
1261
1647
  catch {
1262
- return c.json({ file, content: null, ...(legacy ? { legacy: true } : {}) });
1648
+ const runtimeDiagnostic = RUNTIME_DIAGNOSTIC_FILE_RE.test(file);
1649
+ return c.json({
1650
+ file,
1651
+ content: null,
1652
+ ...(legacy ? { legacy: true } : {}),
1653
+ ...(runtimeDiagnostic ? { readonly: true, readonlyReason: "runtime-diagnostic" } : {}),
1654
+ });
1263
1655
  }
1264
1656
  });
1265
1657
  // --- Analytics ---
@@ -1299,6 +1691,87 @@ export function createStudioServer(initialConfig, root) {
1299
1691
  });
1300
1692
  return c.json({ status: "drafting", bookId: id });
1301
1693
  });
1694
+ app.get("/api/v1/books/:id/eval", async (c) => {
1695
+ const id = c.req.param("id");
1696
+ const chapters = c.req.query("chapters");
1697
+ try {
1698
+ return c.json(await evaluateBookQuality({ state, bookId: id, chapters }));
1699
+ }
1700
+ catch (e) {
1701
+ return c.json({ error: String(e) }, 500);
1702
+ }
1703
+ });
1704
+ app.post("/api/v1/books/:id/consolidate", async (c) => {
1705
+ const id = c.req.param("id");
1706
+ try {
1707
+ const pipelineConfig = await buildPipelineConfig();
1708
+ const consolidator = new ConsolidatorAgent({
1709
+ client: pipelineConfig.client,
1710
+ model: pipelineConfig.model,
1711
+ projectRoot: root,
1712
+ });
1713
+ const result = await consolidator.consolidate(state.bookDir(id));
1714
+ broadcast("consolidate:complete", { bookId: id, ...result });
1715
+ return c.json(result);
1716
+ }
1717
+ catch (e) {
1718
+ broadcast("consolidate:error", { bookId: id, error: String(e) });
1719
+ return c.json({ error: String(e) }, 500);
1720
+ }
1721
+ });
1722
+ app.post("/api/v1/books/:id/plan", async (c) => {
1723
+ const id = c.req.param("id");
1724
+ const body = await c.req.json().catch(() => ({ context: undefined }));
1725
+ try {
1726
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
1727
+ return c.json(await pipeline.planChapter(id, body.context));
1728
+ }
1729
+ catch (e) {
1730
+ return c.json({ error: String(e) }, 500);
1731
+ }
1732
+ });
1733
+ app.post("/api/v1/books/:id/compose", async (c) => {
1734
+ const id = c.req.param("id");
1735
+ const body = await c.req.json().catch(() => ({ context: undefined }));
1736
+ try {
1737
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
1738
+ return c.json(await pipeline.composeChapter(id, body.context));
1739
+ }
1740
+ catch (e) {
1741
+ return c.json({ error: String(e) }, 500);
1742
+ }
1743
+ });
1744
+ app.post("/api/v1/books/:id/repair-state/:chapter", async (c) => {
1745
+ const id = c.req.param("id");
1746
+ const chapterNum = parseInt(c.req.param("chapter"), 10);
1747
+ try {
1748
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
1749
+ const result = await pipeline.repairChapterState(id, chapterNum);
1750
+ broadcast("repair-state:complete", { bookId: id, chapter: chapterNum });
1751
+ return c.json(result);
1752
+ }
1753
+ catch (e) {
1754
+ broadcast("repair-state:error", { bookId: id, chapter: chapterNum, error: String(e) });
1755
+ return c.json({ error: String(e) }, 500);
1756
+ }
1757
+ });
1758
+ app.post("/api/v1/books/:id/foundation/revise", async (c) => {
1759
+ const id = c.req.param("id");
1760
+ const { feedback } = await c.req.json().catch(() => ({ feedback: undefined }));
1761
+ if (!feedback?.trim()) {
1762
+ return c.json({ error: "feedback is required" }, 400);
1763
+ }
1764
+ try {
1765
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
1766
+ await pipeline.reviseFoundation(id, feedback.trim());
1767
+ broadcast("foundation:revised", { bookId: id });
1768
+ return c.json({ ok: true });
1769
+ }
1770
+ catch (e) {
1771
+ broadcast("foundation:error", { bookId: id, error: String(e) });
1772
+ return c.json({ error: String(e) }, 500);
1773
+ }
1774
+ });
1302
1775
  app.post("/api/v1/books/:id/chapters/:num/approve", async (c) => {
1303
1776
  const id = c.req.param("id");
1304
1777
  const num = parseInt(c.req.param("num"), 10);
@@ -1398,6 +1871,45 @@ export function createStudioServer(initialConfig, root) {
1398
1871
  envConfig,
1399
1872
  });
1400
1873
  });
1874
+ app.post("/api/v1/services/config/import-env", async (c) => {
1875
+ const env = await readEffectiveEnvConfigValues(root);
1876
+ if (!env || !env.values.apiKey) {
1877
+ return c.json({
1878
+ error: "未检测到可导入的 LLM 环境变量配置,或缺少 INKOS_LLM_API_KEY。",
1879
+ }, 400);
1880
+ }
1881
+ const config = await loadRawConfig(root);
1882
+ config.llm = config.llm ?? {};
1883
+ const llm = config.llm;
1884
+ const existingServices = normalizeServiceConfig(llm.services);
1885
+ const explicitService = env.values.service?.trim();
1886
+ const guessedService = env.values.baseUrl ? guessServiceFromBaseUrl(env.values.baseUrl) : null;
1887
+ const service = explicitService || guessedService || "custom";
1888
+ const entry = service === "custom"
1889
+ ? {
1890
+ service: "custom",
1891
+ name: "Env LLM",
1892
+ ...(env.values.baseUrl ? { baseUrl: env.values.baseUrl } : {}),
1893
+ }
1894
+ : { service };
1895
+ const serviceKey = serviceConfigKey(entry);
1896
+ llm.services = mergeServiceConfig(existingServices, [entry]);
1897
+ llm.service = serviceKey;
1898
+ llm.configSource = "studio";
1899
+ if (env.values.model)
1900
+ llm.defaultModel = env.values.model;
1901
+ syncTopLevelLlmMirror(llm);
1902
+ const secrets = await loadSecrets(root);
1903
+ secrets.services[serviceKey] = { apiKey: env.values.apiKey };
1904
+ await saveSecrets(root, secrets);
1905
+ await saveRawConfig(root, config);
1906
+ return c.json({
1907
+ ok: true,
1908
+ source: env.source,
1909
+ service: serviceKey,
1910
+ defaultModel: env.values.model ?? null,
1911
+ });
1912
+ });
1401
1913
  app.put("/api/v1/services/config", async (c) => {
1402
1914
  const body = await c.req.json();
1403
1915
  const config = await loadRawConfig(root);
@@ -1431,16 +1943,24 @@ export function createStudioServer(initialConfig, root) {
1431
1943
  const llm = config.llm ?? {};
1432
1944
  const cover = normalizeCoverConfig(llm.cover);
1433
1945
  const secrets = await loadSecrets(root);
1946
+ const keyFor = (service) => Boolean(secrets.services[coverSecretKey(service)]?.apiKey || secrets.services[service]?.apiKey);
1947
+ // "Configured" = a cover service is selected AND has a key, OR a cover
1948
+ // endpoint is provided via env (the CLI/power-user path). This is the gate
1949
+ // for the Play auto-illustration toggles.
1950
+ const envConfigured = Boolean((process.env.INKOS_COVER_BASE_URL || process.env.INKOS_COVER_ENDPOINT)
1951
+ && (process.env.INKOS_COVER_API_KEY || keyFor("kkaiapi")));
1952
+ const configured = Boolean(cover?.service && keyFor(cover.service)) || envConfigured;
1434
1953
  return c.json({
1435
1954
  service: cover?.service ?? null,
1436
1955
  model: cover?.model ?? null,
1956
+ configured,
1437
1957
  providers: COVER_PROVIDER_PRESETS.map((provider) => ({
1438
1958
  service: provider.service,
1439
1959
  label: provider.label,
1440
1960
  baseUrl: provider.baseUrl,
1441
1961
  defaultModel: provider.defaultModel,
1442
1962
  models: provider.models,
1443
- connected: Boolean(secrets.services[coverSecretKey(provider.service)]?.apiKey || secrets.services[provider.service]?.apiKey),
1963
+ connected: keyFor(provider.service),
1444
1964
  })),
1445
1965
  });
1446
1966
  });
@@ -1732,6 +2252,45 @@ export function createStudioServer(initialConfig, root) {
1732
2252
  return c.json({ error: String(e) }, 500);
1733
2253
  }
1734
2254
  });
2255
+ app.get("/api/v1/project/input-governance-mode", async (c) => {
2256
+ const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
2257
+ return c.json({ mode: raw.inputGovernanceMode === "legacy" ? "legacy" : "v2" });
2258
+ });
2259
+ app.put("/api/v1/project/input-governance-mode", async (c) => {
2260
+ const { mode } = await c.req.json();
2261
+ const parsed = InputGovernanceModeSchema.safeParse(mode);
2262
+ if (!parsed.success) {
2263
+ return c.json({ error: "mode must be legacy or v2" }, 400);
2264
+ }
2265
+ const configPath = join(root, "inkos.json");
2266
+ const raw = JSON.parse(await readFile(configPath, "utf-8"));
2267
+ raw.inputGovernanceMode = parsed.data;
2268
+ const { writeFile: writeFileFs } = await import("node:fs/promises");
2269
+ await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
2270
+ return c.json({ ok: true, mode: parsed.data });
2271
+ });
2272
+ app.get("/api/v1/project/detection", async (c) => {
2273
+ const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
2274
+ return c.json({ detection: raw.detection ?? null });
2275
+ });
2276
+ app.put("/api/v1/project/detection", async (c) => {
2277
+ const { detection } = await c.req.json();
2278
+ const configPath = join(root, "inkos.json");
2279
+ const raw = JSON.parse(await readFile(configPath, "utf-8"));
2280
+ if (detection === null) {
2281
+ delete raw.detection;
2282
+ }
2283
+ else {
2284
+ const parsed = DetectionConfigSchema.safeParse(detection);
2285
+ if (!parsed.success) {
2286
+ return c.json({ error: parsed.error.issues.map((issue) => issue.message).join("; ") }, 400);
2287
+ }
2288
+ raw.detection = parsed.data;
2289
+ }
2290
+ const { writeFile: writeFileFs } = await import("node:fs/promises");
2291
+ await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
2292
+ return c.json({ ok: true, detection: raw.detection ?? null });
2293
+ });
1735
2294
  // --- Truth files browser ---
1736
2295
  app.get("/api/v1/books/:id/truth", async (c) => {
1737
2296
  const id = c.req.param("id");
@@ -1740,7 +2299,7 @@ export function createStudioServer(initialConfig, root) {
1740
2299
  async function listDir(subdir) {
1741
2300
  try {
1742
2301
  const entries = await readdir(join(storyDir, subdir));
1743
- return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
2302
+ return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json") || f.endsWith(".yaml"));
1744
2303
  }
1745
2304
  catch {
1746
2305
  return [];
@@ -1753,9 +2312,12 @@ export function createStudioServer(initialConfig, root) {
1753
2312
  try {
1754
2313
  const content = await readFile(join(storyDir, relPath), "utf-8");
1755
2314
  const isShim = LEGACY_SHIM_FILES.has(relPath) && newLayout;
2315
+ const isRuntimeDiagnostic = RUNTIME_DIAGNOSTIC_FILE_RE.test(relPath);
1756
2316
  const entry = isShim
1757
2317
  ? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
1758
- : { name: relPath, size: content.length, preview: content.slice(0, 200) };
2318
+ : isRuntimeDiagnostic
2319
+ ? { name: relPath, size: content.length, preview: content.slice(0, 200), readonly: true, readonlyReason: "runtime-diagnostic" }
2320
+ : { name: relPath, size: content.length, preview: content.slice(0, 200) };
1759
2321
  return entry;
1760
2322
  }
1761
2323
  catch {
@@ -1773,6 +2335,9 @@ export function createStudioServer(initialConfig, root) {
1773
2335
  const minorRolesZh = (await listDir("roles/次要角色")).map((f) => `roles/次要角色/${f}`);
1774
2336
  const majorRolesEn = (await listDir("roles/major")).map((f) => `roles/major/${f}`);
1775
2337
  const minorRolesEn = (await listDir("roles/minor")).map((f) => `roles/minor/${f}`);
2338
+ const runtimeFiles = (await listDir("runtime"))
2339
+ .map((f) => `runtime/${f}`)
2340
+ .filter((f) => RUNTIME_DIAGNOSTIC_FILE_RE.test(f));
1776
2341
  const all = [
1777
2342
  ...flatFiles,
1778
2343
  ...outlineFiles,
@@ -1780,6 +2345,7 @@ export function createStudioServer(initialConfig, root) {
1780
2345
  ...minorRolesZh,
1781
2346
  ...majorRolesEn,
1782
2347
  ...minorRolesEn,
2348
+ ...runtimeFiles,
1783
2349
  ];
1784
2350
  const described = await Promise.all(all.map(describe));
1785
2351
  const result = described.filter((x) => x !== null);
@@ -1875,6 +2441,142 @@ export function createStudioServer(initialConfig, root) {
1875
2441
  activeBookId,
1876
2442
  });
1877
2443
  });
2444
+ // Play worlds are created and advanced by the play_start / play_step agent
2445
+ // tools (worldId === sessionId). The HUD only needs to read a run's state,
2446
+ // so just the run-detail endpoint remains; the old save-slot list/create
2447
+ // endpoints were only used by the removed standalone play page.
2448
+ app.get("/api/v1/play/runs/:worldId/:runId", async (c) => {
2449
+ const worldId = normalizeApiBookId(c.req.param("worldId"), "worldId") ?? "default-world";
2450
+ const runId = normalizeApiBookId(c.req.param("runId"), "runId") ?? "default-run";
2451
+ const store = new PlayStore(root);
2452
+ const db = createPlayDB(store.runDir(worldId, runId));
2453
+ const [transcript, currentState, world] = await Promise.all([
2454
+ store.readTranscript(worldId, runId),
2455
+ store.loadCurrentState(worldId, runId).catch(() => null),
2456
+ store.loadWorld(worldId).catch(() => null),
2457
+ ]);
2458
+ const graph = db.snapshot();
2459
+ db.close?.();
2460
+ // Merge generated illustrations (decoupled sidecar) onto entities so the
2461
+ // HUD can render portraits/stills without touching the event-sourced graph.
2462
+ const runDir = store.runDir(worldId, runId);
2463
+ const [manifest, imageSettings] = await Promise.all([
2464
+ readPlayImageManifest(runDir),
2465
+ readPlayImageSettings(runDir),
2466
+ ]);
2467
+ const imageUrlFor = (file) => file ? `/api/v1/play/runs/${encodeURIComponent(worldId)}/${encodeURIComponent(runId)}/images/${encodeURIComponent(file)}` : undefined;
2468
+ const sceneImageUrls = Object.fromEntries(Object.entries(manifest)
2469
+ .filter(([key, entry]) => key.startsWith("scene-turn-") && entry.status === "ready" && entry.file)
2470
+ .map(([key, entry]) => [key, imageUrlFor(entry.file)]));
2471
+ const entitiesWithImages = (graph.entities ?? []).map((entity) => {
2472
+ const entry = manifest[entity.id];
2473
+ return entry?.status === "ready" && entry.file
2474
+ ? { ...entity, imageUrl: imageUrlFor(entry.file) }
2475
+ : entity;
2476
+ });
2477
+ // Illustration of the current moment, if one was generated for this turn.
2478
+ const sceneTurn = currentState?.turn ?? 0;
2479
+ const sceneEntry = manifest[`scene-turn-${sceneTurn}`];
2480
+ const sceneImageUrl = sceneEntry?.status === "ready" ? imageUrlFor(sceneEntry.file) : undefined;
2481
+ return c.json({
2482
+ worldId,
2483
+ runId,
2484
+ title: world?.title ?? null,
2485
+ transcript,
2486
+ currentState,
2487
+ graph: { ...graph, entities: entitiesWithImages },
2488
+ imageSettings,
2489
+ sceneImageUrls,
2490
+ ...(sceneImageUrl ? { sceneImageUrl } : {}),
2491
+ });
2492
+ });
2493
+ // --- Interactive-world illustration (Play auto-config images) ---
2494
+ app.put("/api/v1/play/runs/:worldId/:runId/image-settings", async (c) => {
2495
+ const worldId = normalizeApiBookId(c.req.param("worldId"), "worldId") ?? "default-world";
2496
+ const runId = normalizeApiBookId(c.req.param("runId"), "runId") ?? "default-run";
2497
+ const body = await c.req.json().catch(() => ({}));
2498
+ const settings = {
2499
+ actors: Boolean(body.actors),
2500
+ moments: Boolean(body.moments),
2501
+ inventory: Boolean(body.inventory),
2502
+ };
2503
+ const runDir = new PlayStore(root).runDir(worldId, runId);
2504
+ await writePlayImageSettings(runDir, settings);
2505
+ return c.json({ ok: true, imageSettings: settings });
2506
+ });
2507
+ app.post("/api/v1/play/runs/:worldId/:runId/generate-image", async (c) => {
2508
+ const worldId = normalizeApiBookId(c.req.param("worldId"), "worldId") ?? "default-world";
2509
+ const runId = normalizeApiBookId(c.req.param("runId"), "runId") ?? "default-run";
2510
+ const body = await c.req.json().catch(() => ({ target: "entity" }));
2511
+ const store = new PlayStore(root);
2512
+ const runDir = store.runDir(worldId, runId);
2513
+ const [world, currentState] = await Promise.all([
2514
+ store.loadWorld(worldId).catch(() => null),
2515
+ store.loadCurrentState(worldId, runId).catch(() => null),
2516
+ ]);
2517
+ const worldContext = world
2518
+ ? {
2519
+ premise: world.premise,
2520
+ worldContract: world.worldContract,
2521
+ visualContract: world.visualContract,
2522
+ }
2523
+ : undefined;
2524
+ let key;
2525
+ let prompt;
2526
+ if (body.target === "scene") {
2527
+ // The current moment defaults to the rendered scene projection so the UI
2528
+ // can offer a one-tap "illustrate this moment" without re-sending prose.
2529
+ const sceneText = ((body.sceneText ?? "").trim()
2530
+ || (await store.readProjection(worldId, runId, "projections/scene.md").catch(() => "")).trim());
2531
+ if (!sceneText)
2532
+ return c.json({ error: "no current scene to illustrate" }, 400);
2533
+ key = body.sceneKey?.trim() || `scene-turn-${currentState?.turn ?? 0}`;
2534
+ prompt = buildPlaySceneImagePrompt(sceneText, worldContext);
2535
+ }
2536
+ else {
2537
+ const entityId = body.entityId?.trim();
2538
+ if (!entityId)
2539
+ return c.json({ error: "entityId is required for an entity image" }, 400);
2540
+ const db = createPlayDB(runDir);
2541
+ const graph = db.snapshot();
2542
+ db.close?.();
2543
+ const entity = (graph.entities ?? []).find((e) => e.id === entityId);
2544
+ if (!entity)
2545
+ return c.json({ error: `entity not found: ${entityId}` }, 404);
2546
+ key = entity.id;
2547
+ prompt = buildPlayEntityImagePrompt(entity, worldContext);
2548
+ }
2549
+ try {
2550
+ const entry = await generatePlayImage({ root, runDir, key, prompt });
2551
+ const url = entry.status === "ready" && entry.file
2552
+ ? `/api/v1/play/runs/${encodeURIComponent(worldId)}/${encodeURIComponent(runId)}/images/${encodeURIComponent(entry.file)}`
2553
+ : undefined;
2554
+ return c.json({ key, ok: entry.status === "ready", ...entry, ...(url ? { url } : {}) });
2555
+ }
2556
+ catch (e) {
2557
+ // Resolution failure = cover API not configured.
2558
+ return c.json({ error: e instanceof Error ? e.message : String(e), needsCoverConfig: true }, 400);
2559
+ }
2560
+ });
2561
+ app.get("/api/v1/play/runs/:worldId/:runId/images/:file", async (c) => {
2562
+ const worldId = normalizeApiBookId(c.req.param("worldId"), "worldId") ?? "default-world";
2563
+ const runId = normalizeApiBookId(c.req.param("runId"), "runId") ?? "default-run";
2564
+ const file = c.req.param("file");
2565
+ if (!file || file.includes("/") || file.includes("..") || file.includes("\0")) {
2566
+ return c.json({ error: "Invalid image file" }, 400);
2567
+ }
2568
+ const runDir = new PlayStore(root).runDir(worldId, runId);
2569
+ try {
2570
+ const { readFile: readFileFs } = await import("node:fs/promises");
2571
+ const content = await readFileFs(join(runDir, "images", file));
2572
+ const ext = file.split(".").pop()?.toLowerCase() ?? "";
2573
+ const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
2574
+ return new Response(content, { headers: { "Content-Type": contentType } });
2575
+ }
2576
+ catch {
2577
+ return c.notFound();
2578
+ }
2579
+ });
1878
2580
  // -- Per-book session endpoints --
1879
2581
  app.get("/api/v1/sessions", async (c) => {
1880
2582
  const bookId = c.req.query("bookId");
@@ -1890,10 +2592,24 @@ export function createStudioServer(initialConfig, root) {
1890
2592
  app.post("/api/v1/sessions", async (c) => {
1891
2593
  const body = await c.req.json().catch(() => ({}));
1892
2594
  const bookId = normalizeApiBookId(body.bookId, "bookId");
2595
+ const sessionKind = normalizeStudioSessionKind(body.sessionKind, bookId ? "book" : "chat");
2596
+ const playMode = normalizeStudioPlayMode(body.playMode);
1893
2597
  const sessionId = body.sessionId;
1894
2598
  // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
1895
2599
  const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
1896
- const session = await createAndPersistBookSession(root, bookId, safeSessionId);
2600
+ const session = await createAndPersistBookSession(root, bookId, safeSessionId, sessionKind, ...(playMode ? [{ playMode }] : []));
2601
+ return c.json({ session });
2602
+ });
2603
+ app.put("/api/v1/sessions/:sessionId/play-mode", async (c) => {
2604
+ const body = await c.req.json().catch(() => ({}));
2605
+ const playMode = normalizeStudioPlayMode(body.playMode);
2606
+ if (!playMode) {
2607
+ throw new ApiError(400, "INVALID_PLAY_MODE", "playMode is required");
2608
+ }
2609
+ const existing = await loadBookSession(root, c.req.param("sessionId"));
2610
+ if (!existing)
2611
+ return c.json({ error: "Session not found" }, 404);
2612
+ const session = await createAndPersistBookSession(root, existing.bookId, existing.sessionId, existing.sessionKind, { playMode });
1897
2613
  return c.json({ session });
1898
2614
  });
1899
2615
  app.put("/api/v1/sessions/:sessionId", async (c) => {
@@ -1914,7 +2630,7 @@ export function createStudioServer(initialConfig, root) {
1914
2630
  return c.json({ ok: true });
1915
2631
  });
1916
2632
  app.post("/api/v1/agent", async (c) => {
1917
- const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json();
2633
+ const { instruction, activeBookId, sessionId: reqSessionId, sessionKind: reqSessionKind, actionSource: reqActionSource, requestedIntent: reqRequestedIntent, actionPayload: reqActionPayload, playMode: reqPlayMode, model: reqModel, service: reqService, } = await c.req.json();
1918
2634
  const sessionId = reqSessionId;
1919
2635
  if (!instruction?.trim()) {
1920
2636
  return c.json({ error: "No instruction provided" }, 400);
@@ -1926,7 +2642,11 @@ export function createStudioServer(initialConfig, root) {
1926
2642
  const message = nonTextModelMessage(reqModel);
1927
2643
  return c.json({ error: message, response: message }, 400);
1928
2644
  }
1929
- broadcast("agent:start", { instruction, activeBookId, sessionId });
2645
+ const actionSource = normalizeStudioActionSource(reqActionSource);
2646
+ const requestedIntent = normalizeStudioRequestedIntent(reqRequestedIntent);
2647
+ const actionPayload = normalizeStudioActionPayload(reqActionPayload);
2648
+ const playMode = normalizeStudioPlayMode(reqPlayMode);
2649
+ broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent });
1930
2650
  try {
1931
2651
  // Load config + create LLM client (pipeline created after model resolution)
1932
2652
  const config = await loadCurrentProjectConfig({ requireApiKey: false });
@@ -1944,9 +2664,15 @@ export function createStudioServer(initialConfig, root) {
1944
2664
  throw new ApiError(409, "SESSION_BOOK_MISMATCH", `Session ${bookSession.sessionId} is bound to ${persistedBookId}, not ${requestedActiveBookId}`);
1945
2665
  }
1946
2666
  const agentBookId = requestedActiveBookId ?? persistedBookId;
2667
+ const sessionKind = normalizeStudioSessionKind(reqSessionKind, bookSession.sessionKind ?? (agentBookId ? "book" : "chat"));
2668
+ if (bookSession.sessionKind !== sessionKind || (playMode && bookSession.playMode !== playMode)) {
2669
+ const updatedSession = await createAndPersistBookSession(root, bookSession.bookId, bookSession.sessionId, sessionKind, ...(playMode ? [{ playMode }] : []));
2670
+ bookSession = updatedSession;
2671
+ }
2672
+ let activeBookConfig = null;
1947
2673
  if (agentBookId) {
1948
2674
  try {
1949
- await state.loadBookConfig(agentBookId);
2675
+ activeBookConfig = await state.loadBookConfig(agentBookId);
1950
2676
  }
1951
2677
  catch {
1952
2678
  throw new ApiError(404, "BOOK_NOT_FOUND", `Book not found: ${agentBookId}`);
@@ -1965,12 +2691,14 @@ export function createStudioServer(initialConfig, root) {
1965
2691
  sessionTitleBroadcasted = true;
1966
2692
  }
1967
2693
  };
1968
- const externalEdit = await tryHandleExternalChatEdit({
1969
- root,
1970
- state,
1971
- instruction,
1972
- activeBookId: agentBookId,
1973
- });
2694
+ const externalEdit = requestedIntent === "edit_artifact" || sessionKind === "edit"
2695
+ ? await tryHandleExternalChatEdit({
2696
+ root,
2697
+ state,
2698
+ instruction,
2699
+ activeBookId: agentBookId,
2700
+ })
2701
+ : null;
1974
2702
  if (externalEdit) {
1975
2703
  await appendManualSessionMessages(root, bookSession.sessionId, [{
1976
2704
  role: "assistant",
@@ -1988,13 +2716,14 @@ export function createStudioServer(initialConfig, root) {
1988
2716
  },
1989
2717
  stopReason: "stop",
1990
2718
  timestamp: Date.now(),
1991
- }], instruction);
2719
+ }], instruction, { sessionKind });
1992
2720
  await refreshBookSessionFromTranscript();
1993
- broadcast("agent:complete", { instruction, activeBookId: externalEdit.activeBookId, sessionId: bookSession.sessionId });
2721
+ broadcast("agent:complete", { instruction, activeBookId: externalEdit.activeBookId, sessionId: bookSession.sessionId, sessionKind });
1994
2722
  return c.json({
1995
2723
  response: externalEdit.responseText,
1996
2724
  session: {
1997
2725
  sessionId: bookSession.sessionId,
2726
+ sessionKind,
1998
2727
  ...(externalEdit.activeBookId ? { activeBookId: externalEdit.activeBookId } : {}),
1999
2728
  },
2000
2729
  });
@@ -2086,9 +2815,94 @@ export function createStudioServer(initialConfig, root) {
2086
2815
  currentConfig: config,
2087
2816
  sessionIdForSSE: bookSession.sessionId,
2088
2817
  }));
2089
- if (agentBookId && isWriteNextInstruction(instruction)) {
2818
+ if (requestedIntent && isConfirmedProductionAction({ actionSource, requestedIntent })) {
2819
+ const pendingBookId = requestedIntent === "create_book" && actionPayload?.createBook?.title
2820
+ ? deriveBookIdFromTitle(actionPayload.createBook.title)
2821
+ : null;
2822
+ if (pendingBookId) {
2823
+ bookCreateStatus.set(pendingBookId, { status: "creating" });
2824
+ broadcast("book:creating", {
2825
+ bookId: pendingBookId,
2826
+ title: actionPayload?.createBook?.title ?? pendingBookId,
2827
+ sessionId: streamSessionId,
2828
+ });
2829
+ }
2830
+ try {
2831
+ const exec = await executeConfirmedProductionAction({
2832
+ pipeline,
2833
+ root,
2834
+ sessionId: bookSession.sessionId,
2835
+ streamSessionId,
2836
+ instruction,
2837
+ requestedIntent,
2838
+ actionPayload,
2839
+ ...(playMode ? { playMode } : {}),
2840
+ });
2841
+ let createdBookId = null;
2842
+ if (exec.tool === "sub_agent" && exec.agent === "architect" && exec.status === "completed") {
2843
+ createdBookId = resolveCreatedBookIdFromToolExecs([exec]);
2844
+ if (createdBookId) {
2845
+ try {
2846
+ const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
2847
+ if (migratedSession) {
2848
+ bookSession = migratedSession;
2849
+ }
2850
+ }
2851
+ catch (e) {
2852
+ if (!(e instanceof SessionAlreadyMigratedError)) {
2853
+ throw e;
2854
+ }
2855
+ }
2856
+ const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
2857
+ bookCreateStatus.delete(createdBookId);
2858
+ broadcast("book:created", {
2859
+ bookId: createdBookId,
2860
+ sessionId: bookSession.sessionId,
2861
+ ...(book ? { book } : {}),
2862
+ });
2863
+ }
2864
+ }
2865
+ const responseText = exec.result ?? "已完成。";
2866
+ await appendManualSessionMessages(root, bookSession.sessionId, [
2867
+ manualToolAssistantMessage(responseText, exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
2868
+ ], instruction, manualToolAppendOptions(sessionKind, exec));
2869
+ await refreshBookSessionFromTranscript();
2870
+ broadcast("agent:complete", { instruction, activeBookId: createdBookId ?? agentBookId, sessionId: bookSession.sessionId, sessionKind });
2871
+ return c.json({
2872
+ response: responseText,
2873
+ session: {
2874
+ sessionId: bookSession.sessionId,
2875
+ sessionKind,
2876
+ ...(createdBookId ?? agentBookId ? { activeBookId: createdBookId ?? agentBookId } : {}),
2877
+ },
2878
+ });
2879
+ }
2880
+ catch (error) {
2881
+ const message = error instanceof Error ? error.message : String(error);
2882
+ if (pendingBookId) {
2883
+ bookCreateStatus.set(pendingBookId, { status: "error", error: message });
2884
+ broadcast("book:error", { bookId: pendingBookId, sessionId: streamSessionId, error: message });
2885
+ }
2886
+ if (error instanceof ConfirmedActionExecutionError) {
2887
+ await appendManualSessionMessages(root, bookSession.sessionId, [
2888
+ manualToolAssistantMessage(message, error.exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
2889
+ ], instruction, manualToolAppendOptions(sessionKind, error.exec)).catch(() => undefined);
2890
+ await refreshBookSessionFromTranscript().catch(() => undefined);
2891
+ }
2892
+ broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
2893
+ return c.json({
2894
+ error: { code: "AGENT_ACTION_FAILED", message },
2895
+ response: message,
2896
+ }, 502);
2897
+ }
2898
+ }
2899
+ if (shouldRunDirectWriteNext({ instruction, agentBookId, sessionKind, actionSource, requestedIntent })) {
2900
+ const directWriteBookId = agentBookId;
2901
+ if (!directWriteBookId) {
2902
+ throw new ApiError(400, "BOOK_ID_REQUIRED", "write_next requires an active book");
2903
+ }
2090
2904
  const toolCallId = `direct-writer-${Date.now().toString(36)}`;
2091
- const toolArgs = { agent: "writer", bookId: agentBookId };
2905
+ const toolArgs = { agent: "writer", bookId: directWriteBookId };
2092
2906
  broadcast("tool:start", {
2093
2907
  sessionId: streamSessionId,
2094
2908
  id: toolCallId,
@@ -2097,17 +2911,24 @@ export function createStudioServer(initialConfig, root) {
2097
2911
  stages: PIPELINE_STAGES.writer,
2098
2912
  });
2099
2913
  try {
2100
- const writeResult = await pipeline.writeNextChapter(agentBookId);
2101
- const responseText = [
2102
- `已为 ${agentBookId} 完成第 ${writeResult.chapterNumber} 章`,
2103
- writeResult.title ? `《${writeResult.title}》` : "",
2104
- `,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
2105
- ].join("");
2914
+ const writeResult = await pipeline.writeNextChapter(directWriteBookId);
2915
+ const writeNeedsReview = Boolean(writeResult.status && writeResult.status !== "ready-for-review");
2916
+ const responseText = writeNeedsReview
2917
+ ? [
2918
+ `已为 ${directWriteBookId} 写出第 ${writeResult.chapterNumber} 章`,
2919
+ writeResult.title ? `《${writeResult.title}》` : "",
2920
+ `,字数 ${writeResult.wordCount},但审稿未通过,状态 ${writeResult.status},需要复核后再继续。`,
2921
+ ].join("")
2922
+ : [
2923
+ `已为 ${directWriteBookId} 完成第 ${writeResult.chapterNumber} 章`,
2924
+ writeResult.title ? `《${writeResult.title}》` : "",
2925
+ `,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
2926
+ ].join("");
2106
2927
  const toolResult = {
2107
2928
  content: [{ type: "text", text: responseText }],
2108
2929
  details: {
2109
2930
  kind: "chapter_written",
2110
- bookId: agentBookId,
2931
+ bookId: directWriteBookId,
2111
2932
  chapterNumber: writeResult.chapterNumber,
2112
2933
  title: writeResult.title,
2113
2934
  wordCount: writeResult.wordCount,
@@ -2120,38 +2941,48 @@ export function createStudioServer(initialConfig, root) {
2120
2941
  tool: "sub_agent",
2121
2942
  result: toolResult,
2122
2943
  details: toolResult.details,
2123
- isError: false,
2944
+ isError: writeNeedsReview,
2124
2945
  });
2125
- await appendManualSessionMessages(root, bookSession.sessionId, [{
2126
- role: "assistant",
2127
- content: [{ type: "text", text: responseText }],
2128
- api: "anthropic-messages",
2129
- provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
2130
- model: reqModel ?? config.llm.model,
2131
- usage: {
2132
- input: 0,
2133
- output: 0,
2134
- cacheRead: 0,
2135
- cacheWrite: 0,
2136
- totalTokens: 0,
2137
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2138
- },
2139
- stopReason: "toolUse",
2140
- timestamp: Date.now(),
2141
- }], instruction);
2946
+ const exec = {
2947
+ id: toolCallId,
2948
+ tool: "sub_agent",
2949
+ agent: "writer",
2950
+ label: resolveToolLabel("sub_agent", "writer"),
2951
+ status: writeNeedsReview ? "error" : "completed",
2952
+ args: toolArgs,
2953
+ result: responseText,
2954
+ details: toolResult.details,
2955
+ startedAt: Date.now(),
2956
+ completedAt: Date.now(),
2957
+ };
2958
+ await appendManualSessionMessages(root, bookSession.sessionId, [
2959
+ manualToolAssistantMessage(responseText, exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
2960
+ ], instruction, manualToolAppendOptions(sessionKind, exec));
2142
2961
  await refreshBookSessionFromTranscript();
2143
- broadcast("agent:complete", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId });
2962
+ broadcast("agent:complete", { instruction, activeBookId: directWriteBookId, sessionId: bookSession.sessionId, sessionKind });
2144
2963
  return c.json({
2145
2964
  response: responseText,
2146
2965
  session: {
2147
2966
  sessionId: bookSession.sessionId,
2148
- activeBookId: agentBookId,
2967
+ sessionKind,
2968
+ activeBookId: directWriteBookId,
2149
2969
  },
2150
2970
  });
2151
2971
  }
2152
2972
  catch (error) {
2153
2973
  const message = error instanceof Error ? error.message : String(error);
2154
2974
  const toolResult = { content: [{ type: "text", text: message }] };
2975
+ const exec = {
2976
+ id: toolCallId,
2977
+ tool: "sub_agent",
2978
+ agent: "writer",
2979
+ label: resolveToolLabel("sub_agent", "writer"),
2980
+ status: "error",
2981
+ args: toolArgs,
2982
+ error: message,
2983
+ startedAt: Date.now(),
2984
+ completedAt: Date.now(),
2985
+ };
2155
2986
  broadcast("tool:end", {
2156
2987
  sessionId: streamSessionId,
2157
2988
  id: toolCallId,
@@ -2159,13 +2990,25 @@ export function createStudioServer(initialConfig, root) {
2159
2990
  result: toolResult,
2160
2991
  isError: true,
2161
2992
  });
2162
- broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, error: message });
2993
+ await appendManualSessionMessages(root, bookSession.sessionId, [
2994
+ manualToolAssistantMessage(message, exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
2995
+ ], instruction, manualToolAppendOptions(sessionKind, exec)).catch(() => undefined);
2996
+ await refreshBookSessionFromTranscript().catch(() => undefined);
2997
+ broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
2163
2998
  return c.json({
2164
2999
  error: { code: "AGENT_ACTION_FAILED", message },
2165
3000
  response: message,
2166
3001
  }, 502);
2167
3002
  }
2168
3003
  }
3004
+ // The surface agent should speak the user's language, not just the project default.
3005
+ // Pre-commitment surfaces (chat / play / short / book-create, no book yet) infer it
3006
+ // from the instruction; committed book/edit sessions keep the configured language.
3007
+ // Without this, an English request on a zh-default project gets Chinese replies — and
3008
+ // a Chinese play world, because play_start then infers from the rewritten premise.
3009
+ const configLanguage = config.language === "en" ? "en" : "zh";
3010
+ const bookLanguage = activeBookConfig?.language === "en" ? "en" : activeBookConfig?.language === "zh" ? "zh" : undefined;
3011
+ const surfaceLanguage = agentBookId ? (bookLanguage ?? configLanguage) : inferLanguage(instruction);
2169
3012
  // Run pi-agent session
2170
3013
  const collectedToolExecs = [];
2171
3014
  const result = await runAgentSession({
@@ -2174,8 +3017,19 @@ export function createStudioServer(initialConfig, root) {
2174
3017
  pipeline,
2175
3018
  projectRoot: root,
2176
3019
  bookId: agentBookId,
3020
+ sessionKind,
3021
+ playMode,
3022
+ actionSource,
3023
+ requestedIntent,
3024
+ actionPayload,
2177
3025
  sessionId: bookSession.sessionId,
2178
- language: config.language ?? "zh",
3026
+ language: surfaceLanguage,
3027
+ onContextCompression: (event) => {
3028
+ broadcast("context:compression", {
3029
+ sessionId: streamSessionId,
3030
+ ...event,
3031
+ });
3032
+ },
2179
3033
  onEvent: (event) => {
2180
3034
  if (event.type === "message_update") {
2181
3035
  const ame = event.assistantMessageEvent;
@@ -2271,7 +3125,7 @@ export function createStudioServer(initialConfig, root) {
2271
3125
  const actionExecutionError = validateAgentActionExecution({
2272
3126
  instruction,
2273
3127
  agentBookId,
2274
- responseText: result.responseText,
3128
+ requestedIntent,
2275
3129
  collectedToolExecs,
2276
3130
  });
2277
3131
  if (actionExecutionError) {
@@ -2290,6 +3144,12 @@ export function createStudioServer(initialConfig, root) {
2290
3144
  return null;
2291
3145
  if (broadcastedCreatedBookId === createdBookId)
2292
3146
  return createdBookId;
3147
+ if (!await completeBookExists(join(root, "books", createdBookId))) {
3148
+ const error = "Book creation artifact is incomplete on disk.";
3149
+ bookCreateStatus.set(createdBookId, { status: "error", error });
3150
+ broadcast("book:error", { bookId: createdBookId, sessionId: bookSession.sessionId, error });
3151
+ return null;
3152
+ }
2293
3153
  try {
2294
3154
  const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
2295
3155
  if (migratedSession) {
@@ -2312,95 +3172,54 @@ export function createStudioServer(initialConfig, root) {
2312
3172
  return createdBookId;
2313
3173
  };
2314
3174
  if (!result.responseText) {
2315
- if (result.errorMessage) {
2316
- if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
2317
- await finalizeCreatedBook();
2318
- }
3175
+ if (hasSuccessfulToolExec(collectedToolExecs, "propose_action")) {
3176
+ await refreshBookSessionFromTranscript();
3177
+ broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId, sessionKind });
2319
3178
  return c.json({
2320
- error: { code: "AGENT_LLM_ERROR", message: result.errorMessage },
2321
- response: result.errorMessage,
2322
- }, 502);
2323
- }
2324
- try {
2325
- const fallbackClient = createLLMClient({
2326
- ...config.llm,
2327
- service: configuredEntry?.service ?? reqService ?? config.llm.service,
2328
- model: reqModel ?? config.llm.model,
2329
- apiKey: agentApiKey ?? config.llm.apiKey,
2330
- baseUrl: configuredEntry?.baseUrl ?? "",
2331
- ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
2332
- ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
2333
- });
2334
- const fallback = await chatCompletion(fallbackClient, reqModel ?? config.llm.model, [
2335
- { role: "system", content: buildAgentSystemPrompt(agentBookId, config.language ?? "zh") },
2336
- { role: "user", content: instruction },
2337
- ], { maxTokens: 256 });
2338
- if (fallback.content?.trim()) {
2339
- const actionExecutionError = validateAgentActionExecution({
2340
- instruction,
2341
- agentBookId,
2342
- responseText: fallback.content,
2343
- collectedToolExecs,
2344
- });
2345
- if (actionExecutionError) {
2346
- return c.json({
2347
- error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
2348
- response: actionExecutionError,
2349
- }, 502);
2350
- }
2351
- await appendManualSessionMessages(root, bookSession.sessionId, [{
2352
- role: "assistant",
2353
- content: [{ type: "text", text: fallback.content }],
2354
- api: "anthropic-messages",
2355
- provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
2356
- model: reqModel ?? config.llm.model,
2357
- usage: {
2358
- input: 0,
2359
- output: 0,
2360
- cacheRead: 0,
2361
- cacheWrite: 0,
2362
- totalTokens: 0,
2363
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2364
- },
2365
- stopReason: "stop",
2366
- timestamp: Date.now(),
2367
- }], instruction);
2368
- await refreshBookSessionFromTranscript();
2369
- const createdBookId = await finalizeCreatedBook();
2370
- return c.json({
2371
- response: fallback.content,
2372
- session: {
2373
- sessionId: bookSession.sessionId,
2374
- ...(createdBookId ? { activeBookId: createdBookId } : {}),
2375
- },
2376
- });
2377
- }
2378
- }
2379
- catch {
2380
- // fall through to probe-based diagnosis below
2381
- }
2382
- try {
2383
- const probeClient = createLLMClient({
2384
- ...config.llm,
2385
- service: configuredEntry?.service ?? reqService ?? config.llm.service,
2386
- model: reqModel ?? config.llm.model,
2387
- apiKey: agentApiKey ?? config.llm.apiKey,
2388
- baseUrl: configuredEntry?.baseUrl ?? "",
2389
- ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
2390
- ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
3179
+ response: "",
3180
+ session: {
3181
+ sessionId: bookSession.sessionId,
3182
+ sessionKind,
3183
+ ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
3184
+ },
2391
3185
  });
2392
- await chatCompletion(probeClient, reqModel ?? config.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
2393
3186
  }
2394
- catch (probeError) {
2395
- const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
3187
+ if (result.errorMessage) {
2396
3188
  if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
2397
3189
  await finalizeCreatedBook();
2398
3190
  }
3191
+ const failure = formatAgentFailure(result.errorMessage);
2399
3192
  return c.json({
2400
- error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
2401
- response: probeMessage,
3193
+ error: { code: failure.code, message: failure.message },
3194
+ response: failure.message,
3195
+ }, failure.status);
3196
+ }
3197
+ const actionExecutionError = validateAgentActionExecution({
3198
+ instruction,
3199
+ agentBookId,
3200
+ requestedIntent,
3201
+ collectedToolExecs,
3202
+ });
3203
+ if (actionExecutionError) {
3204
+ return c.json({
3205
+ error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
3206
+ response: actionExecutionError,
2402
3207
  }, 502);
2403
3208
  }
3209
+ await refreshBookSessionFromTranscript();
3210
+ const createdBookId = await finalizeCreatedBook();
3211
+ if (requestedIntent || createdBookId || hasSuccessfulToolResult(collectedToolExecs)) {
3212
+ const responseSessionKind = bookSession.sessionKind ?? sessionKind;
3213
+ broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId, sessionKind: responseSessionKind });
3214
+ return c.json({
3215
+ response: "",
3216
+ session: {
3217
+ sessionId: bookSession.sessionId,
3218
+ sessionKind: responseSessionKind,
3219
+ ...(createdBookId ?? bookSession.bookId ? { activeBookId: createdBookId ?? bookSession.bookId } : {}),
3220
+ },
3221
+ });
3222
+ }
2404
3223
  const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
2405
3224
  if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
2406
3225
  await finalizeCreatedBook();
@@ -2412,11 +3231,13 @@ export function createStudioServer(initialConfig, root) {
2412
3231
  }
2413
3232
  await refreshBookSessionFromTranscript();
2414
3233
  await finalizeCreatedBook();
2415
- broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
3234
+ const responseSessionKind = bookSession.sessionKind ?? sessionKind;
3235
+ broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId, sessionKind: responseSessionKind });
2416
3236
  return c.json({
2417
3237
  response: result.responseText,
2418
3238
  session: {
2419
3239
  sessionId: bookSession.sessionId,
3240
+ sessionKind: responseSessionKind,
2420
3241
  ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
2421
3242
  },
2422
3243
  });
@@ -2430,7 +3251,7 @@ export function createStudioServer(initialConfig, root) {
2430
3251
  throw new ApiError(409, "SESSION_ALREADY_MIGRATED", migratedMessage);
2431
3252
  }
2432
3253
  const msg = e instanceof Error ? e.message : String(e);
2433
- broadcast("agent:error", { instruction, activeBookId, sessionId, error: msg });
3254
+ broadcast("agent:error", { instruction, activeBookId, sessionId, sessionKind: reqSessionKind, error: msg });
2434
3255
  // Agent busy — return 429 with user-friendly message
2435
3256
  if (/already processing|prompt.*queue/i.test(msg)) {
2436
3257
  return c.json({
@@ -2438,7 +3259,8 @@ export function createStudioServer(initialConfig, root) {
2438
3259
  response: "正在处理中,请等待当前操作完成后再发送。",
2439
3260
  }, 429);
2440
3261
  }
2441
- return c.json({ error: { code: "AGENT_ERROR", message: msg } }, 500);
3262
+ const failure = formatAgentFailure(msg);
3263
+ return c.json({ error: { code: failure.code, message: failure.message } }, failure.status);
2442
3264
  }
2443
3265
  });
2444
3266
  // --- Language setup ---
@@ -2620,6 +3442,21 @@ export function createStudioServer(initialConfig, root) {
2620
3442
  await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
2621
3443
  return c.json({ ok: true });
2622
3444
  });
3445
+ // --- Chapter review mode (C4a: auto pipeline vs manual checkpoint) ---
3446
+ app.get("/api/v1/project/chapter-review-mode", async (c) => {
3447
+ const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
3448
+ return c.json({ mode: raw.writing?.reviewMode === "manual" ? "manual" : "auto" });
3449
+ });
3450
+ app.put("/api/v1/project/chapter-review-mode", async (c) => {
3451
+ const { mode } = await c.req.json();
3452
+ const next = mode === "manual" ? "manual" : "auto";
3453
+ const configPath = join(root, "inkos.json");
3454
+ const raw = JSON.parse(await readFile(configPath, "utf-8"));
3455
+ raw.writing = { ...(raw.writing ?? {}), reviewMode: next };
3456
+ const { writeFile: writeFileFs } = await import("node:fs/promises");
3457
+ await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
3458
+ return c.json({ ok: true, mode: next });
3459
+ });
2623
3460
  // --- Notify channels ---
2624
3461
  app.get("/api/v1/project/notify", async (c) => {
2625
3462
  const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
@@ -2673,6 +3510,9 @@ export function createStudioServer(initialConfig, root) {
2673
3510
  return c.json({ error: "Legacy compat shim; edit outline/story_frame.md instead" }, 400);
2674
3511
  }
2675
3512
  }
3513
+ if (RUNTIME_DIAGNOSTIC_FILE_RE.test(file)) {
3514
+ return c.json({ error: "Runtime diagnostic files are read-only" }, 400);
3515
+ }
2676
3516
  const { content } = await c.req.json();
2677
3517
  const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
2678
3518
  const { dirname: dirnameFs } = await import("node:path");
@@ -3005,6 +3845,98 @@ export function createStudioServer(initialConfig, root) {
3005
3845
  return c.json({ error: String(e) }, 500);
3006
3846
  }
3007
3847
  });
3848
+ // --- Side-story (番外) init: companion book inheriting a parent's canon ---
3849
+ app.post("/api/v1/spinoff/init", async (c) => {
3850
+ const body = await c.req.json();
3851
+ if (!body.title?.trim() || !body.parentBookId?.trim()) {
3852
+ return c.json({ error: "title and parentBookId are required" }, 400);
3853
+ }
3854
+ let parent;
3855
+ try {
3856
+ parent = await state.loadBookConfig(body.parentBookId);
3857
+ }
3858
+ catch {
3859
+ return c.json({ error: `Parent book "${body.parentBookId}" not found` }, 404);
3860
+ }
3861
+ const language = (body.language ?? parent.language);
3862
+ const now = new Date().toISOString();
3863
+ const bookConfig = buildStudioBookConfig({
3864
+ title: body.title,
3865
+ genre: body.genre ?? parent.genre ?? "other",
3866
+ platform: body.platform ?? parent.platform,
3867
+ targetChapters: body.targetChapters ?? parent.targetChapters,
3868
+ chapterWordCount: body.chapterWordCount ?? parent.chapterWordCount,
3869
+ ...(language ? { language } : {}),
3870
+ }, now);
3871
+ const bookId = bookConfig.id;
3872
+ if (!bookId) {
3873
+ return c.json({ error: "Could not derive a valid book id from title" }, 400);
3874
+ }
3875
+ if (await completeBookExists(state.bookDir(bookId))) {
3876
+ return c.json({ error: `Book "${bookId}" already exists` }, 409);
3877
+ }
3878
+ broadcast("spinoff:start", { bookId, title: body.title, parentBookId: body.parentBookId });
3879
+ bookCreateStatus.set(bookId, { status: "creating" });
3880
+ void (async () => {
3881
+ try {
3882
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
3883
+ await pipeline.initSpinoffBook(bookConfig, body.parentBookId, body.direction);
3884
+ const book = await loadStudioBookListSummary(state, bookId).catch(() => undefined);
3885
+ bookCreateStatus.delete(bookId);
3886
+ broadcast("spinoff:complete", { bookId });
3887
+ broadcast("book:created", { bookId, ...(book ? { book } : {}) });
3888
+ }
3889
+ catch (e) {
3890
+ const error = e instanceof Error ? e.message : String(e);
3891
+ bookCreateStatus.set(bookId, { status: "error", error });
3892
+ broadcast("spinoff:error", { bookId, error });
3893
+ broadcast("book:error", { bookId, error });
3894
+ }
3895
+ })();
3896
+ return c.json({ status: "creating", bookId });
3897
+ });
3898
+ // --- Imitation (仿写) init: original story imitating a reference work's style ---
3899
+ app.post("/api/v1/imitation/init", async (c) => {
3900
+ const body = await c.req.json();
3901
+ if (!body.title?.trim() || !body.referenceText?.trim() || !body.storyIdea?.trim()) {
3902
+ return c.json({ error: "title, referenceText and storyIdea are required" }, 400);
3903
+ }
3904
+ const now = new Date().toISOString();
3905
+ const bookConfig = buildStudioBookConfig({
3906
+ title: body.title,
3907
+ genre: body.genre ?? "other",
3908
+ platform: body.platform,
3909
+ targetChapters: body.targetChapters,
3910
+ chapterWordCount: body.chapterWordCount,
3911
+ ...(body.language ? { language: body.language } : {}),
3912
+ }, now);
3913
+ const bookId = bookConfig.id;
3914
+ if (!bookId) {
3915
+ return c.json({ error: "Could not derive a valid book id from title" }, 400);
3916
+ }
3917
+ if (await completeBookExists(state.bookDir(bookId))) {
3918
+ return c.json({ error: `Book "${bookId}" already exists` }, 409);
3919
+ }
3920
+ broadcast("imitation:start", { bookId, title: body.title });
3921
+ bookCreateStatus.set(bookId, { status: "creating" });
3922
+ void (async () => {
3923
+ try {
3924
+ const pipeline = new PipelineRunner(await buildPipelineConfig());
3925
+ await pipeline.initImitationBook(bookConfig, body.referenceText, body.storyIdea, body.sourceName);
3926
+ const book = await loadStudioBookListSummary(state, bookId).catch(() => undefined);
3927
+ bookCreateStatus.delete(bookId);
3928
+ broadcast("imitation:complete", { bookId });
3929
+ broadcast("book:created", { bookId, ...(book ? { book } : {}) });
3930
+ }
3931
+ catch (e) {
3932
+ const error = e instanceof Error ? e.message : String(e);
3933
+ bookCreateStatus.set(bookId, { status: "error", error });
3934
+ broadcast("imitation:error", { bookId, error });
3935
+ broadcast("book:error", { bookId, error });
3936
+ }
3937
+ })();
3938
+ return c.json({ status: "creating", bookId });
3939
+ });
3008
3940
  // --- Radar Scan ---
3009
3941
  app.post("/api/v1/radar/scan", async (c) => {
3010
3942
  broadcast("radar:start", {});
@@ -3049,7 +3981,10 @@ export function createStudioServer(initialConfig, root) {
3049
3981
  try {
3050
3982
  const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
3051
3983
  const service = currentConfig.llm.service ?? currentConfig.llm.provider;
3052
- const probe = await probeServiceCapabilities({
3984
+ // Hard overall budget so the diagnostics page never hangs on a slow /
3985
+ // rate-limited upstream — if we can't confirm connectivity quickly, report
3986
+ // it as not-connected rather than spinning.
3987
+ const probe = await withTimeout(probeServiceCapabilities({
3053
3988
  root,
3054
3989
  service,
3055
3990
  apiKey: currentConfig.llm.apiKey,
@@ -3058,10 +3993,10 @@ export function createStudioServer(initialConfig, root) {
3058
3993
  preferredStream: currentConfig.llm.stream,
3059
3994
  preferredModel: currentConfig.llm.model,
3060
3995
  proxyUrl: currentConfig.llm.proxyUrl,
3061
- });
3996
+ }), DOCTOR_LLM_PROBE_BUDGET_MS, "doctor llm probe");
3062
3997
  checks.llmConnected = probe.ok;
3063
3998
  }
3064
- catch { /* ignore */ }
3999
+ catch { /* slow/unreachable upstream — leave llmConnected false */ }
3065
4000
  return c.json(checks);
3066
4001
  });
3067
4002
  return app;