@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.
- package/dist/api/server.d.ts +4 -2
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +772 -28
- package/dist/api/server.js.map +1 -1
- package/dist/assets/FilmWizard-DEDrqO5H.js +1 -0
- package/dist/assets/FlowView-C5ap-Sga.css +1 -0
- package/dist/assets/FlowView-DUBkKiff.js +7 -0
- package/dist/assets/{_baseUniq-DBJGQpfz.js → _baseUniq-DYxaUtZI.js} +1 -1
- package/dist/assets/{arc-DLB2F6Ls.js → arc-CHuRYqSh.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BFuSKPVt.js → architectureDiagram-Q4EWVU46-BRVULDTV.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CfyajMt_.js → blockDiagram-DXYQGD6D-DKQr_EU5.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-C39iCuyt.js → c4Diagram-AHTNJAMY-6OA3wnaj.js} +1 -1
- package/dist/assets/channel-CbescFQS.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CjszI3xK.js → chunk-4BX2VUAB-RPzgJJNb.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-_Pw5P4zU.js → chunk-4TB4RGXK-C4RNzHuu.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CyhEfvSu.js → chunk-55IACEB6-D7M7RO-J.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CRB16gOE.js → chunk-EDXVE4YY-DZY9MFLO.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CQNtSClR.js → chunk-FMBD7UC4-QMoMGo7W.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CrIyKPh2.js → chunk-OYMX7WX6-CQWjKnzn.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dp8aCjyj.js → chunk-QZHKN3VN-D16FUhg8.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-BSoI3fCo.js → chunk-YZCP3GAM-CqRHihc7.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BbJjgfyw.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BbJjgfyw.js +1 -0
- package/dist/assets/clone-D07SvwIC.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-ib3OuAHZ.js → cose-bilkent-S5V4N54A-bfoOQLsv.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cgoc5396.js → dagre-KV5264BT-BCyLituE.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-zbMY2ZKa.js → diagram-5BDNPKRD-CPkN139r.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-CQ-7PjEw.js → diagram-G4DWMVQ6-DFLt39fl.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-Ckk-FPgp.js → diagram-MMDJMWI5-DYYmrlVW.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BHSvAn1k.js → diagram-TYMM5635-BZ3Pv-hE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CIYuDmiL.js → erDiagram-SMLLAGMA-CAKauIJm.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CK59h9F2.js → flowDiagram-DWJPFMVM-KB2eEgGe.js} +1 -1
- package/dist/assets/ganttDiagram-T4ZO3ILL-JMol99Z2.js +292 -0
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHi_pqWs.js → gitGraphDiagram-UUTBAWPF-Bj2wACW5.js} +1 -1
- package/dist/assets/{graph-Bf6kO5hN.js → graph-mzOZJsmK.js} +1 -1
- package/dist/assets/{highlighted-body-OFNGDK62-ByVUxUAP.js → highlighted-body-OFNGDK62-C5zsRw66.js} +1 -1
- package/dist/assets/index-BXxzots5.js +1381 -0
- package/dist/assets/index-Pc31QmUJ.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-OZSf158n.js → infoDiagram-42DDH7IO-DJiZjg4e.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DOXrXOVm.js → ishikawaDiagram-UXIWVN3A-D3C3S2GG.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CI_P_1qn.js → journeyDiagram-VCZTEJTY-oox1ioYZ.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CbVduhit.js → kanban-definition-6JOO6SKY-DmK_6JO5.js} +1 -1
- package/dist/assets/{layout-CDYPyKY3.js → layout-BU0zwXna.js} +1 -1
- package/dist/assets/linear-BTTy9rEg.js +1 -0
- package/dist/assets/{min-BzBfMBhf.js → min-CxsTU35L.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-CeKF_r9_.js → mindmap-definition-QFDTVHPH-B9yvRvsY.js} +3 -3
- package/dist/assets/{pieDiagram-DEJITSTG-xuXMIHlg.js → pieDiagram-DEJITSTG-BRDyhVEN.js} +2 -2
- package/dist/assets/{quadrantDiagram-34T5L4WZ-bTuh0Bqf.js → quadrantDiagram-34T5L4WZ-CHuq8duY.js} +2 -2
- package/dist/assets/{requirementDiagram-MS252O5E-FG5TUOrq.js → requirementDiagram-MS252O5E-CPZMaTta.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-C0OOFrN9.js → sankeyDiagram-XADWPNL6-ukpqhioK.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-Ckpth_cI.js → sequenceDiagram-FGHM5R23-_HE4wLAQ.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-D8RrbVIs.js → stateDiagram-FHFEXIEX-CzvbU_vB.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Zuwgf5Ss.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-uuHijcIj.js → timeline-definition-GMOUNBTQ-CjOUyryC.js} +1 -1
- package/dist/assets/value-BRre6T9m.js +1 -0
- package/dist/assets/{vennDiagram-DHZGUBPP-CsyOphhl.js → vennDiagram-DHZGUBPP-ByHmBMvF.js} +4 -4
- package/dist/assets/{wardley-RL74JXVD-2nbtdaa2.js → wardley-RL74JXVD-BJB7tkWD.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CF4OidJg.js → wardleyDiagram-NUSXRM2D-CPNSDUV6.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-S50l-zj6.js → xychartDiagram-5P7HB3ND-BPC25pDH.js} +2 -2
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/dist/assets/channel-DU6XMRUx.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DDpzyIVF.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DDpzyIVF.js +0 -1
- package/dist/assets/clone-CJju3aYb.js +0 -1
- package/dist/assets/ganttDiagram-T4ZO3ILL-Bdt7WCSI.js +0 -292
- package/dist/assets/index-CEMuJFJQ.js +0 -1366
- package/dist/assets/index-Dfjchve7.css +0 -1
- package/dist/assets/linear-Dh9hHPIC.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-OK_IuyjR.js +0 -1
package/dist/api/server.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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,
|
|
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,
|
|
61
|
+
return r.content.slice(0, 2000);
|
|
54
62
|
if (typeof r.text === "string")
|
|
55
|
-
return r.text.slice(0,
|
|
63
|
+
return r.text.slice(0, 2000);
|
|
56
64
|
}
|
|
57
|
-
return String(result).slice(0,
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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:
|
|
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 =
|
|
3448
|
-
return c.json({ mode: raw
|
|
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
|
|
3453
|
-
const
|
|
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
|
-
|
|
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 ---
|