@actalk/inkos-studio 1.5.0 → 1.6.1-canary.49.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 (70) hide show
  1. package/dist/api/server.d.ts +4 -2
  2. package/dist/api/server.d.ts.map +1 -1
  3. package/dist/api/server.js +772 -28
  4. package/dist/api/server.js.map +1 -1
  5. package/dist/assets/FilmWizard-DEDrqO5H.js +1 -0
  6. package/dist/assets/FlowView-C5ap-Sga.css +1 -0
  7. package/dist/assets/FlowView-DUBkKiff.js +7 -0
  8. package/dist/assets/{_baseUniq-DBJGQpfz.js → _baseUniq-DYxaUtZI.js} +1 -1
  9. package/dist/assets/{arc-DLB2F6Ls.js → arc-CHuRYqSh.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-BFuSKPVt.js → architectureDiagram-Q4EWVU46-BRVULDTV.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CfyajMt_.js → blockDiagram-DXYQGD6D-DKQr_EU5.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-C39iCuyt.js → c4Diagram-AHTNJAMY-6OA3wnaj.js} +1 -1
  13. package/dist/assets/channel-CbescFQS.js +1 -0
  14. package/dist/assets/{chunk-4BX2VUAB-CjszI3xK.js → chunk-4BX2VUAB-RPzgJJNb.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-_Pw5P4zU.js → chunk-4TB4RGXK-C4RNzHuu.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-CyhEfvSu.js → chunk-55IACEB6-D7M7RO-J.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-CRB16gOE.js → chunk-EDXVE4YY-DZY9MFLO.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-CQNtSClR.js → chunk-FMBD7UC4-QMoMGo7W.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-CrIyKPh2.js → chunk-OYMX7WX6-CQWjKnzn.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Dp8aCjyj.js → chunk-QZHKN3VN-D16FUhg8.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-BSoI3fCo.js → chunk-YZCP3GAM-CqRHihc7.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-BbJjgfyw.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-BbJjgfyw.js +1 -0
  24. package/dist/assets/clone-D07SvwIC.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-ib3OuAHZ.js → cose-bilkent-S5V4N54A-bfoOQLsv.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-Cgoc5396.js → dagre-KV5264BT-BCyLituE.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-zbMY2ZKa.js → diagram-5BDNPKRD-CPkN139r.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-CQ-7PjEw.js → diagram-G4DWMVQ6-DFLt39fl.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-Ckk-FPgp.js → diagram-MMDJMWI5-DYYmrlVW.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-BHSvAn1k.js → diagram-TYMM5635-BZ3Pv-hE.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-CIYuDmiL.js → erDiagram-SMLLAGMA-CAKauIJm.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-CK59h9F2.js → flowDiagram-DWJPFMVM-KB2eEgGe.js} +1 -1
  33. package/dist/assets/ganttDiagram-T4ZO3ILL-JMol99Z2.js +292 -0
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHi_pqWs.js → gitGraphDiagram-UUTBAWPF-Bj2wACW5.js} +1 -1
  35. package/dist/assets/{graph-Bf6kO5hN.js → graph-mzOZJsmK.js} +1 -1
  36. package/dist/assets/{highlighted-body-OFNGDK62-ByVUxUAP.js → highlighted-body-OFNGDK62-C5zsRw66.js} +1 -1
  37. package/dist/assets/index-BXxzots5.js +1381 -0
  38. package/dist/assets/index-Pc31QmUJ.css +1 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-OZSf158n.js → infoDiagram-42DDH7IO-DJiZjg4e.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DOXrXOVm.js → ishikawaDiagram-UXIWVN3A-D3C3S2GG.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CI_P_1qn.js → journeyDiagram-VCZTEJTY-oox1ioYZ.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-CbVduhit.js → kanban-definition-6JOO6SKY-DmK_6JO5.js} +1 -1
  43. package/dist/assets/{layout-CDYPyKY3.js → layout-BU0zwXna.js} +1 -1
  44. package/dist/assets/linear-BTTy9rEg.js +1 -0
  45. package/dist/assets/{min-BzBfMBhf.js → min-CxsTU35L.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-CeKF_r9_.js → mindmap-definition-QFDTVHPH-B9yvRvsY.js} +3 -3
  47. package/dist/assets/{pieDiagram-DEJITSTG-xuXMIHlg.js → pieDiagram-DEJITSTG-BRDyhVEN.js} +2 -2
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-bTuh0Bqf.js → quadrantDiagram-34T5L4WZ-CHuq8duY.js} +2 -2
  49. package/dist/assets/{requirementDiagram-MS252O5E-FG5TUOrq.js → requirementDiagram-MS252O5E-CPZMaTta.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-C0OOFrN9.js → sankeyDiagram-XADWPNL6-ukpqhioK.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-Ckpth_cI.js → sequenceDiagram-FGHM5R23-_HE4wLAQ.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-D8RrbVIs.js → stateDiagram-FHFEXIEX-CzvbU_vB.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Zuwgf5Ss.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-uuHijcIj.js → timeline-definition-GMOUNBTQ-CjOUyryC.js} +1 -1
  55. package/dist/assets/value-BRre6T9m.js +1 -0
  56. package/dist/assets/{vennDiagram-DHZGUBPP-CsyOphhl.js → vennDiagram-DHZGUBPP-ByHmBMvF.js} +4 -4
  57. package/dist/assets/{wardley-RL74JXVD-2nbtdaa2.js → wardley-RL74JXVD-BJB7tkWD.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-NUSXRM2D-CF4OidJg.js → wardleyDiagram-NUSXRM2D-CPNSDUV6.js} +1 -1
  59. package/dist/assets/{xychartDiagram-5P7HB3ND-S50l-zj6.js → xychartDiagram-5P7HB3ND-BPC25pDH.js} +2 -2
  60. package/dist/index.html +2 -2
  61. package/package.json +6 -3
  62. package/dist/assets/channel-DU6XMRUx.js +0 -1
  63. package/dist/assets/classDiagram-6PBFFD2Q-DDpzyIVF.js +0 -1
  64. package/dist/assets/classDiagram-v2-HSJHXN6E-DDpzyIVF.js +0 -1
  65. package/dist/assets/clone-CJju3aYb.js +0 -1
  66. package/dist/assets/ganttDiagram-T4ZO3ILL-Bdt7WCSI.js +0 -292
  67. package/dist/assets/index-CEMuJFJQ.js +0 -1366
  68. package/dist/assets/index-Dfjchve7.css +0 -1
  69. package/dist/assets/linear-Dh9hHPIC.js +0 -1
  70. package/dist/assets/stateDiagram-v2-QKLJ7IA2-OK_IuyjR.js +0 -1
@@ -2,9 +2,10 @@ 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, 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
- import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
7
- import { isAbsolute, join, relative, resolve } from "node:path";
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, 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, createGenerateCoverTool, createInteractiveFilmCreationTool, createPlayStartTool, createScriptCreationTool, createShortFictionRunTool, createStoryboardCreationTool, createSubAgentTool, createDraftStructureTool, createConnectChoiceTool, createRemoveNodeTool, filmLLMDepsFromClient, applyGraphDelta, loadStoryGraph, reviewStoryGraph, exportInk, buildPlayableHtml, analyzeEmotionalArcs, analyzePathDistribution, generateNodeImage, defaultNodeImageDeps, } from "@actalk/inkos-core";
7
+ import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
9
  import { isSafeBookId } from "./safety.js";
9
10
  import { ApiError } from "./errors.js";
10
11
  import { buildStudioBookConfig } from "./book-create.js";
@@ -25,6 +26,10 @@ const PIPELINE_STAGES = {
25
26
  ],
26
27
  auditor: ["审计章节"],
27
28
  };
29
+ function attachmentDisposition(fileName) {
30
+ const safeAscii = fileName.replace(/[^A-Za-z0-9._-]+/g, "_") || "download";
31
+ return `attachment; filename="${safeAscii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
32
+ }
28
33
  const AGENT_LABELS = {
29
34
  architect: "建书", writer: "写作", auditor: "审计",
30
35
  reviser: "修订", exporter: "导出",
@@ -33,6 +38,9 @@ const TOOL_LABELS = {
33
38
  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
34
39
  propose_action: "确认动作",
35
40
  short_fiction_run: "短篇生产",
41
+ script_create: "剧本创作",
42
+ storyboard_create: "分镜创作",
43
+ interactive_film_create: "互动影游",
36
44
  generate_cover: "生成封面",
37
45
  play_edit: "编辑互动世界",
38
46
  play_start: "启动互动世界",
@@ -46,15 +54,15 @@ function resolveToolLabel(tool, agent) {
46
54
  }
47
55
  function summarizeResult(result) {
48
56
  if (typeof result === "string")
49
- return result.slice(0, 200);
57
+ return result.slice(0, 2000);
50
58
  if (result && typeof result === "object") {
51
59
  const r = result;
52
60
  if (typeof r.content === "string")
53
- return r.content.slice(0, 200);
61
+ return r.content.slice(0, 2000);
54
62
  if (typeof r.text === "string")
55
- return r.text.slice(0, 200);
63
+ return r.text.slice(0, 2000);
56
64
  }
57
- return String(result).slice(0, 200);
65
+ return String(result).slice(0, 2000);
58
66
  }
59
67
  function compareServiceListItems(left, right) {
60
68
  const priority = ["kkaiapi", "openrouter", "newapi", "siliconcloud"];
@@ -65,6 +73,76 @@ function compareServiceListItems(left, right) {
65
73
  }
66
74
  return 0;
67
75
  }
76
+ async function buildTarArchive(sourceDir, packageRootName) {
77
+ const files = await listArchiveFiles(sourceDir);
78
+ const chunks = [];
79
+ for (const file of files) {
80
+ const payload = await readFile(join(sourceDir, file));
81
+ const archiveName = normalizeArchivePath(join(packageRootName, file));
82
+ chunks.push(createTarHeader(archiveName, payload.byteLength));
83
+ chunks.push(payload);
84
+ const padding = (512 - (payload.byteLength % 512)) % 512;
85
+ if (padding > 0)
86
+ chunks.push(Buffer.alloc(padding));
87
+ }
88
+ chunks.push(Buffer.alloc(1024));
89
+ return Buffer.concat(chunks);
90
+ }
91
+ async function listArchiveFiles(dir, prefix = "") {
92
+ const entries = await readdir(dir, { withFileTypes: true });
93
+ const files = [];
94
+ for (const entry of entries) {
95
+ if (entry.name === ".DS_Store")
96
+ continue;
97
+ const relativePath = prefix ? join(prefix, entry.name) : entry.name;
98
+ const fullPath = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ files.push(...await listArchiveFiles(fullPath, relativePath));
101
+ }
102
+ else if (entry.isFile()) {
103
+ files.push(normalizeArchivePath(relativePath));
104
+ }
105
+ else {
106
+ const info = await stat(fullPath).catch(() => null);
107
+ if (info?.isFile())
108
+ files.push(normalizeArchivePath(relativePath));
109
+ }
110
+ }
111
+ return files.sort((a, b) => a.localeCompare(b));
112
+ }
113
+ function normalizeArchivePath(path) {
114
+ return path.replace(/\\/g, "/").replace(/^\/+/g, "");
115
+ }
116
+ function createTarHeader(name, size) {
117
+ const header = Buffer.alloc(512, 0);
118
+ writeTarString(header, 0, 100, name);
119
+ writeTarOctal(header, 100, 8, 0o644);
120
+ writeTarOctal(header, 108, 8, 0);
121
+ writeTarOctal(header, 116, 8, 0);
122
+ writeTarOctal(header, 124, 12, size);
123
+ writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000));
124
+ header.fill(0x20, 148, 156);
125
+ header[156] = "0".charCodeAt(0);
126
+ writeTarString(header, 257, 6, "ustar");
127
+ writeTarString(header, 263, 2, "00");
128
+ let checksum = 0;
129
+ for (const byte of header)
130
+ checksum += byte;
131
+ writeTarOctal(header, 148, 8, checksum);
132
+ return header;
133
+ }
134
+ function writeTarString(header, offset, length, value) {
135
+ const encoded = Buffer.from(value);
136
+ if (encoded.byteLength > length) {
137
+ throw new Error(`Archive path is too long for tar header: ${value}`);
138
+ }
139
+ encoded.copy(header, offset);
140
+ }
141
+ function writeTarOctal(header, offset, length, value) {
142
+ const text = value.toString(8).padStart(length - 1, "0").slice(-(length - 1));
143
+ header.write(text, offset, length - 1, "ascii");
144
+ header[offset + length - 1] = 0;
145
+ }
68
146
  function isHeaderSafeApiKey(value) {
69
147
  if (!value)
70
148
  return true;
@@ -143,8 +221,8 @@ function resolveProjectImageFile(root, rawPath) {
143
221
  || relPath.split(/[\\/]+/u).includes("..")) {
144
222
  throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Invalid project file path");
145
223
  }
146
- if (!relPath.startsWith("shorts/") && !relPath.startsWith("covers/")) {
147
- throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Only generated shorts/ and covers/ images can be previewed");
224
+ if (!relPath.startsWith("shorts/") && !relPath.startsWith("covers/") && !relPath.startsWith("interactive-films/")) {
225
+ throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Only generated shorts/, covers/, interactive-films/ images can be previewed");
148
226
  }
149
227
  const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
150
228
  const contentTypes = {
@@ -164,6 +242,46 @@ function resolveProjectImageFile(root, rawPath) {
164
242
  }
165
243
  return { resolved, contentType };
166
244
  }
245
+ function normalizeProjectGeneratedPath(root, rawPath, code) {
246
+ let relPath;
247
+ try {
248
+ relPath = decodeURIComponent(rawPath).replace(/^\/+/u, "");
249
+ }
250
+ catch {
251
+ throw new ApiError(400, code, "Invalid project artifact path");
252
+ }
253
+ if (!relPath
254
+ || relPath.includes("\0")
255
+ || isAbsolute(relPath)
256
+ || relPath.split(/[\\/]+/u).includes("..")) {
257
+ throw new ApiError(400, code, "Invalid project artifact path");
258
+ }
259
+ const allowedRoots = ["dramas/", "storyboards/", "interactive-films/", "shorts/", "covers/"];
260
+ if (!allowedRoots.some((prefix) => relPath.startsWith(prefix))) {
261
+ throw new ApiError(400, code, "Only generated writing artifacts can be opened");
262
+ }
263
+ const resolved = resolve(root, relPath);
264
+ const rel = relative(root, resolved);
265
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
266
+ throw new ApiError(400, code, "Invalid project artifact path");
267
+ }
268
+ return { relPath, resolved };
269
+ }
270
+ function resolveProjectTextArtifactFile(root, rawPath) {
271
+ const file = normalizeProjectGeneratedPath(root, rawPath, "INVALID_PROJECT_ARTIFACT_PATH");
272
+ const ext = file.relPath.split(".").pop()?.toLowerCase() ?? "";
273
+ const contentTypes = {
274
+ md: "text/markdown; charset=utf-8",
275
+ markdown: "text/markdown; charset=utf-8",
276
+ txt: "text/plain; charset=utf-8",
277
+ json: "application/json; charset=utf-8",
278
+ };
279
+ const contentType = contentTypes[ext];
280
+ if (!contentType) {
281
+ throw new ApiError(415, "UNSUPPORTED_PROJECT_ARTIFACT_TYPE", "Unsupported project artifact type");
282
+ }
283
+ return { ...file, contentType };
284
+ }
167
285
  function isLikelyFailedToolResult(exec) {
168
286
  if (exec.status === "error")
169
287
  return true;
@@ -218,6 +336,138 @@ function normalizeStudioActionPayload(value) {
218
336
  throw new ApiError(400, "INVALID_ACTION_PAYLOAD", `Invalid actionPayload: ${message}`);
219
337
  }
220
338
  }
339
+ function normalizeStudioSkillIdList(value, field) {
340
+ try {
341
+ return normalizeCoreSkillIdList(value);
342
+ }
343
+ catch (error) {
344
+ const message = error instanceof Error ? error.message : String(error);
345
+ throw new ApiError(400, "INVALID_SKILL_ID", `Invalid ${field}: ${message}`);
346
+ }
347
+ }
348
+ function normalizeStudioSkillId(value, field = "skillId") {
349
+ const [id] = normalizeStudioSkillIdList([value], field);
350
+ if (!id)
351
+ throw new ApiError(400, "INVALID_SKILL_ID", `Invalid ${field}: empty`);
352
+ return id;
353
+ }
354
+ function projectSkillsDir(root) {
355
+ return join(root, ".inkos", "skills");
356
+ }
357
+ function projectSkillDir(root, id) {
358
+ return join(projectSkillsDir(root), id);
359
+ }
360
+ function projectSkillPath(root, id) {
361
+ return join(projectSkillDir(root, id), "SKILL.md");
362
+ }
363
+ function toStudioSkill(skill, root, projectSkillIds) {
364
+ const projectPath = projectSkillPath(root, skill.id);
365
+ const isProjectFile = projectSkillIds.has(skill.id);
366
+ return {
367
+ id: skill.id,
368
+ name: skill.name,
369
+ description: skill.description,
370
+ whenToUse: skill.whenToUse,
371
+ triggers: skill.triggers,
372
+ sessionKinds: skill.sessionKinds,
373
+ promptPacks: skill.promptPacks,
374
+ toolHints: skill.toolHints,
375
+ contextNeeds: skill.contextNeeds,
376
+ body: skill.body,
377
+ source: isProjectFile ? "project" : skill.source,
378
+ editable: isProjectFile,
379
+ path: isProjectFile ? relative(root, projectPath) : undefined,
380
+ };
381
+ }
382
+ function normalizeSkillPayload(value, idOverride) {
383
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
384
+ throw new ApiError(400, "INVALID_SKILL_PAYLOAD", "Skill payload must be an object");
385
+ }
386
+ const data = value;
387
+ const id = normalizeStudioSkillId(idOverride ?? data.id, "id");
388
+ const textOr = (field, fallback) => {
389
+ const raw = data[field];
390
+ return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
391
+ };
392
+ const stringList = (field) => (Array.isArray(data[field])
393
+ ? data[field]
394
+ .filter((item) => typeof item === "string" && item.trim().length > 0)
395
+ .map((item) => item.trim())
396
+ : []);
397
+ try {
398
+ return CapabilitySkillManifestSchema.parse({
399
+ id,
400
+ name: textOr("name", id),
401
+ description: textOr("description", "Project runtime skill."),
402
+ whenToUse: textOr("whenToUse", "Use when explicitly selected by the user."),
403
+ triggers: stringList("triggers"),
404
+ sessionKinds: stringList("sessionKinds"),
405
+ promptPacks: stringList("promptPacks"),
406
+ toolHints: stringList("toolHints"),
407
+ contextNeeds: Array.isArray(data.contextNeeds) ? data.contextNeeds : [],
408
+ body: typeof data.body === "string" ? data.body : "",
409
+ source: "project",
410
+ });
411
+ }
412
+ catch (error) {
413
+ const message = error instanceof Error ? error.message : String(error);
414
+ throw new ApiError(400, "INVALID_SKILL_PAYLOAD", message);
415
+ }
416
+ }
417
+ function serializeProjectSkill(skill) {
418
+ const frontmatter = {
419
+ id: skill.id,
420
+ name: skill.name,
421
+ description: skill.description,
422
+ whenToUse: skill.whenToUse,
423
+ triggers: skill.triggers,
424
+ sessionKinds: skill.sessionKinds,
425
+ promptPacks: skill.promptPacks,
426
+ toolHints: skill.toolHints,
427
+ contextNeeds: skill.contextNeeds,
428
+ };
429
+ return [
430
+ "---",
431
+ ...Object.entries(frontmatter).map(([key, value]) => `${key}: ${JSON.stringify(value)}`),
432
+ "---",
433
+ skill.body.trim(),
434
+ "",
435
+ ].join("\n");
436
+ }
437
+ async function loadStudioSkills(root) {
438
+ const configured = await loadConfiguredCapabilitySkills({ projectRoot: root });
439
+ const projectSkillIds = await listProjectSkillIds(root);
440
+ const registry = createSkillRegistry({ skills: configured.skills });
441
+ return {
442
+ skills: registry.listSkills().map((skill) => toStudioSkill(skill, root, projectSkillIds)),
443
+ diagnostics: configured.diagnostics,
444
+ };
445
+ }
446
+ async function listProjectSkillIds(root) {
447
+ try {
448
+ const entries = await readdir(projectSkillsDir(root), { withFileTypes: true });
449
+ const ids = new Set();
450
+ for (const entry of entries) {
451
+ if (!entry.isDirectory())
452
+ continue;
453
+ const id = normalizeStudioSkillId(entry.name, "skillId");
454
+ try {
455
+ const info = await stat(projectSkillPath(root, id));
456
+ if (info.isFile())
457
+ ids.add(id);
458
+ }
459
+ catch {
460
+ // Ignore incomplete project skill directories.
461
+ }
462
+ }
463
+ return ids;
464
+ }
465
+ catch (error) {
466
+ if (error.code === "ENOENT")
467
+ return new Set();
468
+ throw error;
469
+ }
470
+ }
221
471
  function normalizeStudioPlayMode(value) {
222
472
  try {
223
473
  return normalizeCorePlayMode(value);
@@ -478,7 +728,12 @@ class ConfirmedActionExecutionError extends Error {
478
728
  }
479
729
  }
480
730
  function suppressManualTextForTool(exec) {
481
- return exec.tool === "play_start" || exec.tool === "play_step" || exec.tool === "play_revise";
731
+ return exec.tool === "play_start"
732
+ || exec.tool === "play_step"
733
+ || exec.tool === "play_revise"
734
+ || exec.tool === "script_create"
735
+ || exec.tool === "storyboard_create"
736
+ || exec.tool === "interactive_film_create";
482
737
  }
483
738
  function manualToolAssistantMessage(responseText, exec, provider, model) {
484
739
  return {
@@ -509,8 +764,14 @@ function isConfirmedProductionAction(args) {
509
764
  return (args.actionSource === "button" || args.actionSource === "slash")
510
765
  && (args.requestedIntent === "create_book"
511
766
  || args.requestedIntent === "short_run"
767
+ || args.requestedIntent === "script_create"
768
+ || args.requestedIntent === "storyboard_create"
769
+ || args.requestedIntent === "interactive_film_create"
512
770
  || args.requestedIntent === "play_start"
513
- || args.requestedIntent === "generate_cover");
771
+ || args.requestedIntent === "generate_cover"
772
+ || args.requestedIntent === "draft_structure"
773
+ || args.requestedIntent === "connect_choice"
774
+ || args.requestedIntent === "remove_node");
514
775
  }
515
776
  function requirePayloadText(value, message) {
516
777
  const text = value?.trim();
@@ -572,6 +833,63 @@ async function executeConfirmedProductionAction(args) {
572
833
  ...(payload?.outputDir ? { outputDir: payload.outputDir } : {}),
573
834
  };
574
835
  }
836
+ else if (args.requestedIntent === "script_create") {
837
+ const payload = actionPayload?.scriptCreate;
838
+ const title = requirePayloadText(payload?.title, "确认创建剧本缺少标题,请重新生成确认卡。");
839
+ tool = createScriptCreationTool(args.pipeline, args.root, { actionPayload });
840
+ params = {
841
+ title,
842
+ instruction: args.instruction,
843
+ ...(payload?.sourceKind ? { sourceKind: payload.sourceKind } : {}),
844
+ ...(payload?.targetFormat ? { targetFormat: payload.targetFormat } : {}),
845
+ ...(payload?.sourceText ? { sourceText: payload.sourceText } : {}),
846
+ ...(payload?.sourcePath ? { sourcePath: payload.sourcePath } : {}),
847
+ ...(payload?.requirements ? { requirements: payload.requirements } : {}),
848
+ ...(payload?.episodeCount ? { episodeCount: payload.episodeCount } : {}),
849
+ ...(payload?.episodeDuration ? { episodeDuration: payload.episodeDuration } : {}),
850
+ ...(payload?.projectId ? { projectId: payload.projectId } : {}),
851
+ ...(payload?.outDir ? { outDir: payload.outDir } : {}),
852
+ };
853
+ }
854
+ else if (args.requestedIntent === "storyboard_create") {
855
+ const payload = actionPayload?.storyboardCreate;
856
+ const title = requirePayloadText(payload?.title, "确认创建分镜缺少标题,请重新生成确认卡。");
857
+ tool = createStoryboardCreationTool(args.pipeline, args.root, { actionPayload });
858
+ params = {
859
+ title,
860
+ instruction: args.instruction,
861
+ ...(payload?.sourceKind ? { sourceKind: payload.sourceKind } : {}),
862
+ ...(payload?.sourceText ? { sourceText: payload.sourceText } : {}),
863
+ ...(payload?.sourcePath ? { sourcePath: payload.sourcePath } : {}),
864
+ ...(payload?.requirements ? { requirements: payload.requirements } : {}),
865
+ ...(payload?.visualStyle ? { visualStyle: payload.visualStyle } : {}),
866
+ ...(payload?.aspectRatio ? { aspectRatio: payload.aspectRatio } : {}),
867
+ ...(payload?.granularity ? { granularity: payload.granularity } : {}),
868
+ ...(payload?.maxShots ? { maxShots: payload.maxShots } : {}),
869
+ ...(payload?.projectId ? { projectId: payload.projectId } : {}),
870
+ ...(payload?.outDir ? { outDir: payload.outDir } : {}),
871
+ };
872
+ }
873
+ else if (args.requestedIntent === "interactive_film_create") {
874
+ const payload = actionPayload?.interactiveFilmCreate;
875
+ const title = requirePayloadText(payload?.title, "确认创建互动影游缺少标题,请重新生成确认卡。");
876
+ tool = createInteractiveFilmCreationTool(args.pipeline, args.root, { actionPayload });
877
+ params = {
878
+ title,
879
+ instruction: args.instruction,
880
+ ...(payload?.sourceKind ? { sourceKind: payload.sourceKind } : {}),
881
+ ...(payload?.sourceText ? { sourceText: payload.sourceText } : {}),
882
+ ...(payload?.sourcePath ? { sourcePath: payload.sourcePath } : {}),
883
+ ...(payload?.requirements ? { requirements: payload.requirements } : {}),
884
+ ...(payload?.targetAudience ? { targetAudience: payload.targetAudience } : {}),
885
+ ...(payload?.episodeCount ? { episodeCount: payload.episodeCount } : {}),
886
+ ...(payload?.episodeDuration ? { episodeDuration: payload.episodeDuration } : {}),
887
+ ...(payload?.budget ? { budget: payload.budget } : {}),
888
+ ...(payload?.referenceMode ? { referenceMode: payload.referenceMode } : {}),
889
+ ...(payload?.projectId ? { projectId: payload.projectId } : {}),
890
+ ...(payload?.outDir ? { outDir: payload.outDir } : {}),
891
+ };
892
+ }
575
893
  else if (args.requestedIntent === "play_start") {
576
894
  const payload = actionPayload?.playStart;
577
895
  const title = requirePayloadText(payload?.title, "确认启动互动世界缺少标题,请重新生成确认卡。");
@@ -600,6 +918,44 @@ async function executeConfirmedProductionAction(args) {
600
918
  ...(payload?.suggestedActions ? { suggestedActions: payload.suggestedActions } : {}),
601
919
  };
602
920
  }
921
+ else if (args.requestedIntent === "draft_structure") {
922
+ const payload = actionPayload?.draftStructure;
923
+ const projectId = payload?.projectId ?? args.bookId;
924
+ if (!projectId)
925
+ throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
926
+ const agentCtx = args.pipeline.createAgentContext("film-authoring", projectId);
927
+ const deps = filmLLMDepsFromClient(agentCtx.client, agentCtx.model);
928
+ tool = createDraftStructureTool(args.root, projectId, deps);
929
+ params = {
930
+ instruction: payload?.instruction?.trim() || args.instruction,
931
+ };
932
+ }
933
+ else if (args.requestedIntent === "connect_choice") {
934
+ const payload = actionPayload?.connectChoice;
935
+ if (!payload?.node) {
936
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认连接选择缺少节点数据,请重新生成确认卡。");
937
+ }
938
+ const projectId = payload?.projectId ?? args.bookId;
939
+ if (!projectId)
940
+ throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
941
+ tool = createConnectChoiceTool(args.root, projectId);
942
+ params = {
943
+ node: payload.node,
944
+ };
945
+ }
946
+ else if (args.requestedIntent === "remove_node") {
947
+ const payload = actionPayload?.removeNode;
948
+ if (!payload?.nodeId) {
949
+ throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认删除节点缺少 nodeId,请重新生成确认卡。");
950
+ }
951
+ const projectId = payload?.projectId ?? args.bookId;
952
+ if (!projectId)
953
+ throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
954
+ tool = createRemoveNodeTool(args.root, projectId);
955
+ params = {
956
+ nodeId: payload.nodeId,
957
+ };
958
+ }
603
959
  else {
604
960
  throw new ApiError(400, "UNSUPPORTED_CONFIRMED_ACTION", `Unsupported confirmed action: ${args.requestedIntent}`);
605
961
  }
@@ -828,6 +1184,38 @@ async function loadRawConfig(root) {
828
1184
  async function saveRawConfig(root, config) {
829
1185
  await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
830
1186
  }
1187
+ function normalizeChapterReviewMode(mode) {
1188
+ return mode === "manual" ? "manual" : "auto";
1189
+ }
1190
+ function readProjectChapterReviewMode(config) {
1191
+ const writing = config.writing && typeof config.writing === "object" && !Array.isArray(config.writing)
1192
+ ? config.writing
1193
+ : {};
1194
+ return normalizeChapterReviewMode(writing.reviewMode);
1195
+ }
1196
+ function readBookChapterReviewMode(rawBook) {
1197
+ const writing = rawBook.writing && typeof rawBook.writing === "object" && !Array.isArray(rawBook.writing)
1198
+ ? rawBook.writing
1199
+ : undefined;
1200
+ if (!writing || writing.reviewMode !== "manual" && writing.reviewMode !== "auto")
1201
+ return undefined;
1202
+ return writing.reviewMode;
1203
+ }
1204
+ async function loadRawBookConfig(root, bookId) {
1205
+ const raw = await readFile(join(root, "books", bookId, "book.json"), "utf-8");
1206
+ return JSON.parse(raw);
1207
+ }
1208
+ async function resolveBookChapterReviewMode(root, bookId, projectMode) {
1209
+ if (!bookId || !isSafeBookId(bookId))
1210
+ return projectMode;
1211
+ try {
1212
+ const rawBook = await loadRawBookConfig(root, bookId);
1213
+ return readBookChapterReviewMode(rawBook) ?? projectMode;
1214
+ }
1215
+ catch {
1216
+ return projectMode;
1217
+ }
1218
+ }
831
1219
  function unquoteEnvValue(value) {
832
1220
  const trimmed = value.trim();
833
1221
  if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
@@ -1295,7 +1683,7 @@ async function probeServiceCapabilities(args) {
1295
1683
  };
1296
1684
  }
1297
1685
  // --- Server factory ---
1298
- export function createStudioServer(initialConfig, root) {
1686
+ export function createStudioServer(initialConfig, root, overrides = {}) {
1299
1687
  const app = new Hono();
1300
1688
  const state = new StateManager(root);
1301
1689
  let cachedConfig = initialConfig;
@@ -1352,6 +1740,8 @@ export function createStudioServer(initialConfig, root) {
1352
1740
  }
1353
1741
  async function buildPipelineConfig(overrides) {
1354
1742
  const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
1743
+ const projectReviewMode = readProjectChapterReviewMode(currentConfig);
1744
+ const chapterReviewMode = await resolveBookChapterReviewMode(root, overrides?.bookIdForSettings, projectReviewMode);
1355
1745
  const scopedSseSink = overrides?.sessionIdForSSE
1356
1746
  ? {
1357
1747
  write(entry) {
@@ -1372,7 +1762,7 @@ export function createStudioServer(initialConfig, root) {
1372
1762
  defaultLLMConfig: currentConfig.llm,
1373
1763
  foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
1374
1764
  writingReviewRetries: currentConfig.writing?.reviewRetries ?? 1,
1375
- chapterReviewMode: currentConfig.writing?.reviewMode === "manual" ? "manual" : "auto",
1765
+ chapterReviewMode,
1376
1766
  modelOverrides: currentConfig.modelOverrides,
1377
1767
  notifyChannels: currentConfig.notify,
1378
1768
  logger,
@@ -1671,7 +2061,7 @@ export function createStudioServer(initialConfig, root) {
1671
2061
  const body = await c.req.json().catch(() => ({ wordCount: undefined }));
1672
2062
  broadcast("write:start", { bookId: id });
1673
2063
  // Fire and forget — progress/completion/errors pushed via SSE
1674
- const pipeline = new PipelineRunner(await buildPipelineConfig());
2064
+ const pipeline = new PipelineRunner(await buildPipelineConfig({ bookIdForSettings: id }));
1675
2065
  pipeline.writeNextChapter(id, body.wordCount).then((result) => {
1676
2066
  broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
1677
2067
  }, (e) => {
@@ -2197,9 +2587,16 @@ export function createStudioServer(initialConfig, root) {
2197
2587
  });
2198
2588
  // --- Project info ---
2199
2589
  app.get("/api/v1/project", async (c) => {
2200
- const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
2201
- // Check if language was explicitly set in inkos.json (not just the schema default)
2202
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
2590
+ let currentConfig;
2591
+ let raw;
2592
+ try {
2593
+ currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
2594
+ // Check if language was explicitly set in inkos.json (not just the schema default)
2595
+ raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
2596
+ }
2597
+ catch (error) {
2598
+ throw new ApiError(500, "PROJECT_CONFIG_INVALID", `Failed to load inkos.json: ${error instanceof Error ? error.message : String(error)}`);
2599
+ }
2203
2600
  const languageExplicit = "language" in raw && raw.language !== "";
2204
2601
  return c.json({
2205
2602
  name: currentConfig.name,
@@ -2212,6 +2609,40 @@ export function createStudioServer(initialConfig, root) {
2212
2609
  temperature: currentConfig.llm.temperature,
2213
2610
  });
2214
2611
  });
2612
+ app.get("/api/v1/skills", async (c) => {
2613
+ const result = await loadStudioSkills(root);
2614
+ return c.json(result);
2615
+ });
2616
+ app.post("/api/v1/skills", async (c) => {
2617
+ const payload = await c.req.json().catch(() => {
2618
+ throw new ApiError(400, "INVALID_SKILL_PAYLOAD", "Skill payload must be JSON");
2619
+ });
2620
+ const skill = normalizeSkillPayload(payload);
2621
+ await mkdir(projectSkillDir(root, skill.id), { recursive: true });
2622
+ await writeFile(projectSkillPath(root, skill.id), serializeProjectSkill(skill), "utf-8");
2623
+ return c.json({ skill: toStudioSkill(skill, root, new Set([skill.id])) });
2624
+ });
2625
+ app.put("/api/v1/skills/:skillId", async (c) => {
2626
+ const id = normalizeStudioSkillId(c.req.param("skillId"), "skillId");
2627
+ const payload = await c.req.json().catch(() => {
2628
+ throw new ApiError(400, "INVALID_SKILL_PAYLOAD", "Skill payload must be JSON");
2629
+ });
2630
+ const skill = normalizeSkillPayload(payload, id);
2631
+ await mkdir(projectSkillDir(root, skill.id), { recursive: true });
2632
+ await writeFile(projectSkillPath(root, skill.id), serializeProjectSkill(skill), "utf-8");
2633
+ return c.json({ skill: toStudioSkill(skill, root, new Set([skill.id])) });
2634
+ });
2635
+ app.delete("/api/v1/skills/:skillId", async (c) => {
2636
+ const id = normalizeStudioSkillId(c.req.param("skillId"), "skillId");
2637
+ try {
2638
+ await access(projectSkillPath(root, id));
2639
+ }
2640
+ catch {
2641
+ throw new ApiError(404, "SKILL_NOT_FOUND", `Project skill not found: ${id}`);
2642
+ }
2643
+ await rm(projectSkillDir(root, id), { recursive: true, force: true });
2644
+ return c.json({ ok: true });
2645
+ });
2215
2646
  app.get("/api/v1/project/files/:file{.+}", async (c) => {
2216
2647
  const file = resolveProjectImageFile(root, c.req.param("file"));
2217
2648
  try {
@@ -2227,6 +2658,39 @@ export function createStudioServer(initialConfig, root) {
2227
2658
  return c.notFound();
2228
2659
  }
2229
2660
  });
2661
+ app.get("/api/v1/project/artifacts/:file{.+}", async (c) => {
2662
+ const file = resolveProjectTextArtifactFile(root, c.req.param("file"));
2663
+ try {
2664
+ const content = await readFile(file.resolved, "utf-8");
2665
+ return c.json({
2666
+ path: file.relPath,
2667
+ content,
2668
+ contentType: file.contentType,
2669
+ size: Buffer.byteLength(content, "utf-8"),
2670
+ });
2671
+ }
2672
+ catch {
2673
+ return c.notFound();
2674
+ }
2675
+ });
2676
+ app.put("/api/v1/project/artifacts/:file{.+}", async (c) => {
2677
+ const file = resolveProjectTextArtifactFile(root, c.req.param("file"));
2678
+ const body = await c.req.json().catch(() => null);
2679
+ const content = body && typeof body === "object" && "content" in body
2680
+ ? body.content
2681
+ : undefined;
2682
+ if (typeof content !== "string") {
2683
+ throw new ApiError(400, "INVALID_PROJECT_ARTIFACT_BODY", "content must be a string");
2684
+ }
2685
+ await mkdir(dirname(file.resolved), { recursive: true });
2686
+ await writeFile(file.resolved, content, "utf-8");
2687
+ return c.json({
2688
+ ok: true,
2689
+ path: file.relPath,
2690
+ contentType: file.contentType,
2691
+ size: Buffer.byteLength(content, "utf-8"),
2692
+ });
2693
+ });
2230
2694
  // --- Config editing ---
2231
2695
  app.put("/api/v1/project", async (c) => {
2232
2696
  const updates = await c.req.json();
@@ -2630,7 +3094,7 @@ export function createStudioServer(initialConfig, root) {
2630
3094
  return c.json({ ok: true });
2631
3095
  });
2632
3096
  app.post("/api/v1/agent", async (c) => {
2633
- const { instruction, activeBookId, sessionId: reqSessionId, sessionKind: reqSessionKind, actionSource: reqActionSource, requestedIntent: reqRequestedIntent, actionPayload: reqActionPayload, playMode: reqPlayMode, model: reqModel, service: reqService, } = await c.req.json();
3097
+ const { instruction, activeBookId, sessionId: reqSessionId, sessionKind: reqSessionKind, actionSource: reqActionSource, requestedIntent: reqRequestedIntent, actionPayload: reqActionPayload, requestedSkills: reqRequestedSkills, disabledSkills: reqDisabledSkills, playMode: reqPlayMode, model: reqModel, service: reqService, } = await c.req.json();
2634
3098
  const sessionId = reqSessionId;
2635
3099
  if (!instruction?.trim()) {
2636
3100
  return c.json({ error: "No instruction provided" }, 400);
@@ -2645,8 +3109,10 @@ export function createStudioServer(initialConfig, root) {
2645
3109
  const actionSource = normalizeStudioActionSource(reqActionSource);
2646
3110
  const requestedIntent = normalizeStudioRequestedIntent(reqRequestedIntent);
2647
3111
  const actionPayload = normalizeStudioActionPayload(reqActionPayload);
3112
+ const requestedSkills = normalizeStudioSkillIdList(reqRequestedSkills, "requestedSkills");
3113
+ const disabledSkills = normalizeStudioSkillIdList(reqDisabledSkills, "disabledSkills");
2648
3114
  const playMode = normalizeStudioPlayMode(reqPlayMode);
2649
- broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent });
3115
+ broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent, requestedSkills });
2650
3116
  try {
2651
3117
  // Load config + create LLM client (pipeline created after model resolution)
2652
3118
  const config = await loadCurrentProjectConfig({ requireApiKey: false });
@@ -2670,7 +3136,7 @@ export function createStudioServer(initialConfig, root) {
2670
3136
  bookSession = updatedSession;
2671
3137
  }
2672
3138
  let activeBookConfig = null;
2673
- if (agentBookId) {
3139
+ if (agentBookId && sessionKind !== "interactive-film-authoring") {
2674
3140
  try {
2675
3141
  activeBookConfig = await state.loadBookConfig(agentBookId);
2676
3142
  }
@@ -2814,6 +3280,7 @@ export function createStudioServer(initialConfig, root) {
2814
3280
  model: reqModel ?? config.llm.model,
2815
3281
  currentConfig: config,
2816
3282
  sessionIdForSSE: bookSession.sessionId,
3283
+ bookIdForSettings: activeBookId ?? undefined,
2817
3284
  }));
2818
3285
  if (requestedIntent && isConfirmedProductionAction({ actionSource, requestedIntent })) {
2819
3286
  const pendingBookId = requestedIntent === "create_book" && actionPayload?.createBook?.title
@@ -2832,6 +3299,7 @@ export function createStudioServer(initialConfig, root) {
2832
3299
  pipeline,
2833
3300
  root,
2834
3301
  sessionId: bookSession.sessionId,
3302
+ bookId: agentBookId,
2835
3303
  streamSessionId,
2836
3304
  instruction,
2837
3305
  requestedIntent,
@@ -2863,13 +3331,15 @@ export function createStudioServer(initialConfig, root) {
2863
3331
  }
2864
3332
  }
2865
3333
  const responseText = exec.result ?? "已完成。";
3334
+ const responseForUser = suppressManualTextForTool(exec) ? "" : responseText;
2866
3335
  await appendManualSessionMessages(root, bookSession.sessionId, [
2867
3336
  manualToolAssistantMessage(responseText, exec, configuredEntry?.service ?? reqService ?? config.llm.provider, reqModel ?? config.llm.model),
2868
3337
  ], instruction, manualToolAppendOptions(sessionKind, exec));
2869
3338
  await refreshBookSessionFromTranscript();
2870
3339
  broadcast("agent:complete", { instruction, activeBookId: createdBookId ?? agentBookId, sessionId: bookSession.sessionId, sessionKind });
2871
3340
  return c.json({
2872
- response: responseText,
3341
+ response: responseForUser,
3342
+ details: { toolExecutions: [exec] },
2873
3343
  session: {
2874
3344
  sessionId: bookSession.sessionId,
2875
3345
  sessionKind,
@@ -3022,6 +3492,8 @@ export function createStudioServer(initialConfig, root) {
3022
3492
  actionSource,
3023
3493
  requestedIntent,
3024
3494
  actionPayload,
3495
+ requestedSkills,
3496
+ disabledSkills,
3025
3497
  sessionId: bookSession.sessionId,
3026
3498
  language: surfaceLanguage,
3027
3499
  onContextCompression: (event) => {
@@ -3182,6 +3654,7 @@ export function createStudioServer(initialConfig, root) {
3182
3654
  sessionKind,
3183
3655
  ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
3184
3656
  },
3657
+ details: { toolExecutions: collectedToolExecs },
3185
3658
  });
3186
3659
  }
3187
3660
  if (result.errorMessage) {
@@ -3442,21 +3915,126 @@ export function createStudioServer(initialConfig, root) {
3442
3915
  await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
3443
3916
  return c.json({ ok: true });
3444
3917
  });
3918
+ // --- Global default model ---
3919
+ app.get("/api/v1/project/default-model", async (c) => {
3920
+ const raw = await loadRawConfig(root);
3921
+ const llm = raw.llm && typeof raw.llm === "object" && !Array.isArray(raw.llm)
3922
+ ? raw.llm
3923
+ : {};
3924
+ return c.json({
3925
+ service: typeof llm.service === "string" ? llm.service : null,
3926
+ defaultModel: typeof llm.defaultModel === "string" && llm.defaultModel.trim()
3927
+ ? llm.defaultModel
3928
+ : typeof llm.model === "string" && llm.model.trim()
3929
+ ? llm.model
3930
+ : null,
3931
+ });
3932
+ });
3933
+ app.put("/api/v1/project/default-model", async (c) => {
3934
+ const body = await c.req.json();
3935
+ const defaultModel = typeof body.defaultModel === "string" ? body.defaultModel.trim() : "";
3936
+ if (!defaultModel)
3937
+ return c.json({ error: "defaultModel is required" }, 400);
3938
+ const raw = await loadRawConfig(root);
3939
+ raw.llm = raw.llm && typeof raw.llm === "object" && !Array.isArray(raw.llm) ? raw.llm : {};
3940
+ const llm = raw.llm;
3941
+ llm.defaultModel = defaultModel;
3942
+ if (typeof body.service === "string" && body.service.trim()) {
3943
+ llm.service = body.service.trim();
3944
+ }
3945
+ syncTopLevelLlmMirror(llm);
3946
+ await saveRawConfig(root, raw);
3947
+ return c.json({
3948
+ ok: true,
3949
+ service: typeof llm.service === "string" ? llm.service : null,
3950
+ defaultModel,
3951
+ });
3952
+ });
3953
+ // --- Research search provider ---
3954
+ app.get("/api/v1/project/research-search", async (c) => {
3955
+ const raw = await loadRawConfig(root);
3956
+ return c.json({ researchSearch: ResearchSearchConfigSchema.parse(raw.researchSearch ?? {}) });
3957
+ });
3958
+ app.put("/api/v1/project/research-search", async (c) => {
3959
+ const body = await c.req.json();
3960
+ const researchSearch = ResearchSearchConfigSchema.parse(body.researchSearch ?? {});
3961
+ const raw = await loadRawConfig(root);
3962
+ raw.researchSearch = researchSearch;
3963
+ await saveRawConfig(root, raw);
3964
+ return c.json({ ok: true, researchSearch });
3965
+ });
3445
3966
  // --- Chapter review mode (C4a: auto pipeline vs manual checkpoint) ---
3446
3967
  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" });
3968
+ const raw = await loadRawConfig(root);
3969
+ return c.json({ mode: readProjectChapterReviewMode(raw) });
3449
3970
  });
3450
3971
  app.put("/api/v1/project/chapter-review-mode", async (c) => {
3451
3972
  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"));
3973
+ const next = normalizeChapterReviewMode(mode);
3974
+ const raw = await loadRawConfig(root);
3455
3975
  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");
3976
+ await saveRawConfig(root, raw);
3458
3977
  return c.json({ ok: true, mode: next });
3459
3978
  });
3979
+ app.get("/api/v1/books/:id/chapter-review-mode", async (c) => {
3980
+ const bookId = c.req.param("id");
3981
+ if (!isSafeBookId(bookId))
3982
+ return c.json({ error: "Invalid book id" }, 400);
3983
+ try {
3984
+ const [projectConfig, rawBook] = await Promise.all([
3985
+ loadRawConfig(root),
3986
+ loadRawBookConfig(root, bookId),
3987
+ ]);
3988
+ const projectMode = readProjectChapterReviewMode(projectConfig);
3989
+ const bookMode = readBookChapterReviewMode(rawBook);
3990
+ return c.json({
3991
+ mode: bookMode ?? projectMode,
3992
+ bookMode: bookMode ?? null,
3993
+ projectMode,
3994
+ });
3995
+ }
3996
+ catch {
3997
+ return c.json({ error: `Book "${bookId}" not found` }, 404);
3998
+ }
3999
+ });
4000
+ app.put("/api/v1/books/:id/chapter-review-mode", async (c) => {
4001
+ const bookId = c.req.param("id");
4002
+ if (!isSafeBookId(bookId))
4003
+ return c.json({ error: "Invalid book id" }, 400);
4004
+ const { mode } = await c.req.json();
4005
+ const rawBookPath = join(root, "books", bookId, "book.json");
4006
+ try {
4007
+ const [projectConfig, rawBook] = await Promise.all([
4008
+ loadRawConfig(root),
4009
+ loadRawBookConfig(root, bookId),
4010
+ ]);
4011
+ const projectMode = readProjectChapterReviewMode(projectConfig);
4012
+ if (mode === "inherit") {
4013
+ const writing = rawBook.writing && typeof rawBook.writing === "object" && !Array.isArray(rawBook.writing)
4014
+ ? { ...rawBook.writing }
4015
+ : {};
4016
+ delete writing.reviewMode;
4017
+ rawBook.writing = Object.keys(writing).length > 0 ? writing : undefined;
4018
+ }
4019
+ else {
4020
+ rawBook.writing = {
4021
+ ...(rawBook.writing && typeof rawBook.writing === "object" && !Array.isArray(rawBook.writing) ? rawBook.writing : {}),
4022
+ reviewMode: normalizeChapterReviewMode(mode),
4023
+ };
4024
+ }
4025
+ await writeFile(rawBookPath, JSON.stringify(rawBook, null, 2), "utf-8");
4026
+ const bookMode = readBookChapterReviewMode(rawBook);
4027
+ return c.json({
4028
+ ok: true,
4029
+ mode: bookMode ?? projectMode,
4030
+ bookMode: bookMode ?? null,
4031
+ projectMode,
4032
+ });
4033
+ }
4034
+ catch {
4035
+ return c.json({ error: `Book "${bookId}" not found` }, 404);
4036
+ }
4037
+ });
3460
4038
  // --- Notify channels ---
3461
4039
  app.get("/api/v1/project/notify", async (c) => {
3462
4040
  const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
@@ -3999,6 +4577,172 @@ export function createStudioServer(initialConfig, root) {
3999
4577
  catch { /* slow/unreachable upstream — leave llmConnected false */ }
4000
4578
  return c.json(checks);
4001
4579
  });
4580
+ app.get("/api/v1/interactive-films", async (c) => {
4581
+ const filmsDir = join(root, "interactive-films");
4582
+ let entries = [];
4583
+ try {
4584
+ const dirents = await readdir(filmsDir, { withFileTypes: true });
4585
+ entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
4586
+ }
4587
+ catch (err) {
4588
+ if (err.code !== "ENOENT")
4589
+ throw err;
4590
+ }
4591
+ const films = [];
4592
+ for (const projectId of entries) {
4593
+ if (!isSafeBookId(projectId))
4594
+ continue;
4595
+ try {
4596
+ const graph = await loadStoryGraph(root, projectId);
4597
+ if (graph)
4598
+ films.push({ projectId, title: graph.title || projectId });
4599
+ }
4600
+ catch { /* skip dirs without valid story-graph */ }
4601
+ }
4602
+ films.sort((a, b) => a.title.localeCompare(b.title, "zh"));
4603
+ return c.json({ films });
4604
+ });
4605
+ app.post("/api/v1/projects/:id/story-graph/delta", async (c) => {
4606
+ const id = c.req.param("id");
4607
+ if (!isSafeBookId(id)) {
4608
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4609
+ }
4610
+ const { delta } = await c.req.json();
4611
+ const { graph, rev } = await applyGraphDelta({ projectRoot: root, projectId: id, delta: delta });
4612
+ return c.json({ rev, graph });
4613
+ });
4614
+ app.get("/api/v1/projects/:id/story-graph", async (c) => {
4615
+ const id = c.req.param("id");
4616
+ if (!isSafeBookId(id)) {
4617
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4618
+ }
4619
+ const graphPath = join(root, "interactive-films", id, "story-graph.json");
4620
+ try {
4621
+ const raw = await readFile(graphPath, "utf-8");
4622
+ return c.json(JSON.parse(raw));
4623
+ }
4624
+ catch (error) {
4625
+ if (error.code === "ENOENT") {
4626
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4627
+ }
4628
+ throw error;
4629
+ }
4630
+ });
4631
+ app.get("/api/v1/projects/:id/export", async (c) => {
4632
+ const id = c.req.param("id");
4633
+ if (!isSafeBookId(id)) {
4634
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4635
+ }
4636
+ const projectDir = join(root, "interactive-films", id);
4637
+ try {
4638
+ await access(projectDir);
4639
+ const archive = gzipSync(await buildTarArchive(projectDir, id));
4640
+ return new Response(new Uint8Array(archive), {
4641
+ headers: {
4642
+ "Content-Type": "application/gzip",
4643
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(id)}.tar.gz"`,
4644
+ },
4645
+ });
4646
+ }
4647
+ catch (error) {
4648
+ if (error.code === "ENOENT") {
4649
+ return c.json({ error: { code: "NOT_FOUND", message: `interactive film project not found for ${id}` } }, 404);
4650
+ }
4651
+ throw error;
4652
+ }
4653
+ });
4654
+ app.get("/api/v1/projects/:id/story-graph/validation", async (c) => {
4655
+ const id = c.req.param("id");
4656
+ if (!isSafeBookId(id)) {
4657
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4658
+ }
4659
+ const graph = await loadStoryGraph(root, id);
4660
+ if (!graph) {
4661
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4662
+ }
4663
+ return c.json(reviewStoryGraph(graph));
4664
+ });
4665
+ app.get("/api/v1/projects/:id/story-graph/analysis", async (c) => {
4666
+ const id = c.req.param("id");
4667
+ if (!isSafeBookId(id))
4668
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4669
+ const graph = await loadStoryGraph(root, id);
4670
+ if (!graph)
4671
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4672
+ return c.json({ report: reviewStoryGraph(graph), arcs: analyzeEmotionalArcs(graph), distribution: analyzePathDistribution(graph) });
4673
+ });
4674
+ app.get("/api/v1/projects/:id/export/json", async (c) => {
4675
+ const id = c.req.param("id");
4676
+ if (!isSafeBookId(id))
4677
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4678
+ const graph = await loadStoryGraph(root, id);
4679
+ if (!graph)
4680
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4681
+ return new Response(JSON.stringify(graph, null, 2), {
4682
+ headers: {
4683
+ "Content-Type": "application/json; charset=utf-8",
4684
+ "Content-Disposition": attachmentDisposition(`${id}.story-graph.json`),
4685
+ },
4686
+ });
4687
+ });
4688
+ app.get("/api/v1/projects/:id/export/ink", async (c) => {
4689
+ const id = c.req.param("id");
4690
+ if (!isSafeBookId(id))
4691
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4692
+ const graph = await loadStoryGraph(root, id);
4693
+ if (!graph)
4694
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4695
+ return new Response(exportInk(graph), {
4696
+ headers: {
4697
+ "Content-Type": "text/plain; charset=utf-8",
4698
+ "Content-Disposition": attachmentDisposition(`${id}.ink`),
4699
+ },
4700
+ });
4701
+ });
4702
+ app.get("/api/v1/projects/:id/export/html", async (c) => {
4703
+ const id = c.req.param("id");
4704
+ if (!isSafeBookId(id))
4705
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4706
+ const graph = await loadStoryGraph(root, id);
4707
+ if (!graph)
4708
+ return c.json({ error: { code: "NOT_FOUND", message: `story graph not found for ${id}` } }, 404);
4709
+ const assetDataUris = {};
4710
+ for (const node of graph.nodes) {
4711
+ const ref = node.imageSlot?.assetRef;
4712
+ if (!ref || assetDataUris[ref])
4713
+ continue;
4714
+ try {
4715
+ const file = resolveProjectImageFile(root, ref);
4716
+ const buf = await readFile(file.resolved);
4717
+ assetDataUris[ref] = `data:${file.contentType};base64,${buf.toString("base64")}`;
4718
+ }
4719
+ catch (err) {
4720
+ console.warn(`[studio] export/html: skipping assetRef "${ref}" —`, err);
4721
+ }
4722
+ }
4723
+ return new Response(buildPlayableHtml(graph, { assetDataUris }), {
4724
+ headers: {
4725
+ "Content-Type": "text/html; charset=utf-8",
4726
+ "Content-Disposition": attachmentDisposition(`${id}.html`),
4727
+ },
4728
+ });
4729
+ });
4730
+ app.post("/api/v1/projects/:id/nodes/:nodeId/image", async (c) => {
4731
+ const id = c.req.param("id");
4732
+ const nodeId = c.req.param("nodeId");
4733
+ if (!isSafeBookId(id)) {
4734
+ return c.json({ error: { code: "INVALID_ID", message: `invalid project id: ${id}` } }, 400);
4735
+ }
4736
+ const graph = await loadStoryGraph(root, id);
4737
+ const node = graph?.nodes.find((n) => n.id === nodeId);
4738
+ if (!node) {
4739
+ return c.json({ error: { code: "NODE_NOT_FOUND", message: `node ${nodeId} not found` } }, 404);
4740
+ }
4741
+ const deps = overrides.nodeImageGenerator ?? (await defaultNodeImageDeps(root));
4742
+ const { assetRef, delta } = await generateNodeImage({ projectRoot: root, projectId: id, node, deps });
4743
+ const { rev } = await applyGraphDelta({ projectRoot: root, projectId: id, delta });
4744
+ return c.json({ assetRef, rev });
4745
+ });
4002
4746
  return app;
4003
4747
  }
4004
4748
  // --- Standalone runner ---