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