@actalk/inkos-studio 1.3.6 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/api/safety.d.ts +2 -2
  2. package/dist/api/safety.d.ts.map +1 -1
  3. package/dist/api/safety.js +3 -9
  4. package/dist/api/safety.js.map +1 -1
  5. package/dist/api/server.d.ts.map +1 -1
  6. package/dist/api/server.js +679 -158
  7. package/dist/api/server.js.map +1 -1
  8. package/dist/assets/{_baseUniq-D1lvsUv0.js → _baseUniq-DObXPjDJ.js} +1 -1
  9. package/dist/assets/{arc-DWfC7Ioo.js → arc-BdlZFnEp.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-hK0Bbf6b.js → architectureDiagram-Q4EWVU46-DWIamVb1.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CiSHn5nQ.js → blockDiagram-DXYQGD6D-B15h8JDl.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-C2fRvv6n.js → c4Diagram-AHTNJAMY-CLWyVUi5.js} +1 -1
  13. package/dist/assets/channel-CnRwuGVB.js +1 -0
  14. package/dist/assets/{chunk-4BX2VUAB-cV6Ky6lS.js → chunk-4BX2VUAB-CoreBbcY.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-DRikaS1A.js → chunk-4TB4RGXK-hpWBtrjE.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-Dyk-VVkW.js → chunk-55IACEB6-CAWFhQRP.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-DOWoSXk1.js → chunk-EDXVE4YY-3BFURqnV.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-BzurDjWI.js → chunk-FMBD7UC4-VbVgWRBP.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-BKDb4ajW.js → chunk-OYMX7WX6-P0Kgp9FK.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B8wWCxA6.js → chunk-QZHKN3VN-C4tZsUim.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-Co_1Eg8t.js → chunk-YZCP3GAM-DGxN4WQV.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-C2Hsd0Kb.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-C2Hsd0Kb.js +1 -0
  24. package/dist/assets/clone-ULoi9D6A.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-Dg4b_18w.js → cose-bilkent-S5V4N54A-uWd9Sg8T.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-DZ7GGamV.js → dagre-KV5264BT-Bpno7Sui.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-CQXLIXDK.js → diagram-5BDNPKRD-uXKJXOTX.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-CLmTdqBP.js → diagram-G4DWMVQ6-CWAx_MrF.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-cxyC4hjB.js → diagram-MMDJMWI5-C-apgI2M.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-C4XJZ9Y1.js → diagram-TYMM5635-oE6H2pqq.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-TBt_wVCl.js → erDiagram-SMLLAGMA-BfgDxyIM.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-BWrSN5xe.js → flowDiagram-DWJPFMVM-BLkrIcA5.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CtuTJUg4.js → ganttDiagram-T4ZO3ILL-Cgyf68mR.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-D29sTxA1.js → gitGraphDiagram-UUTBAWPF-dXvSLru9.js} +1 -1
  35. package/dist/assets/{graph-CloN3583.js → graph-ClhNJ_no.js} +1 -1
  36. package/dist/assets/{highlighted-body-OFNGDK62-C10Tf0Im.js → highlighted-body-OFNGDK62-CAGQz_wQ.js} +1 -1
  37. package/dist/assets/{index-DQIDPyEq.js → index-CGOCC7tt.js} +245 -245
  38. package/dist/assets/index-Cks9xRnD.css +1 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-B1rtvZwC.js → infoDiagram-42DDH7IO-CoU2GA9H.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BeWh70hn.js → ishikawaDiagram-UXIWVN3A-CNYIiSHo.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-S4jNWnUK.js → journeyDiagram-VCZTEJTY-BIOf8Fr8.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-CfzGPjgv.js → kanban-definition-6JOO6SKY-DoKVT16A.js} +1 -1
  43. package/dist/assets/{layout-Brt7T0Xl.js → layout-BP7JvoUj.js} +1 -1
  44. package/dist/assets/{linear-BUqZJAG0.js → linear-BaHWv3Cv.js} +1 -1
  45. package/dist/assets/{min-BlHJHGD9.js → min-B323zLjI.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-DnqZCBYb.js → mindmap-definition-QFDTVHPH-831I92Ss.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-BWckOPtQ.js → pieDiagram-DEJITSTG-lzn2BmD0.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-D8FUl0ef.js → quadrantDiagram-34T5L4WZ-CUKMogxF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BV_AxS8a.js → requirementDiagram-MS252O5E-BhrbxNGm.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-BsezkvWP.js → sankeyDiagram-XADWPNL6-eCYinXw3.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-CSHHvCke.js → sequenceDiagram-FGHM5R23-BhykTJJs.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-BKwl9kCH.js → stateDiagram-FHFEXIEX-BeJDpoIy.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-C4HmXN_E.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BvGYrKNf.js → timeline-definition-GMOUNBTQ-C-Z1gWQE.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-cD01C6MK.js → vennDiagram-DHZGUBPP-CN_umDEu.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-CWSp-_IX.js → wardley-RL74JXVD-BlJ0F_eo.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DIh7DRrk.js → wardleyDiagram-NUSXRM2D-7eU2x8b0.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-BKxR1LZs.js → xychartDiagram-5P7HB3ND-BVz98NOl.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/package.json +2 -2
  61. package/dist/assets/channel-P0MVlL3z.js +0 -1
  62. package/dist/assets/classDiagram-6PBFFD2Q-6IwJocki.js +0 -1
  63. package/dist/assets/classDiagram-v2-HSJHXN6E-6IwJocki.js +0 -1
  64. package/dist/assets/clone-DSVAkR4q.js +0 -1
  65. package/dist/assets/index-aO_3bBjW.css +0 -1
  66. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqnXAHBi.js +0 -1
@@ -2,9 +2,9 @@ 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, persistBookSession, appendBookSessionMessage, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, runAgentSession, buildAgentSystemPrompt, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, getServiceApiKey, listModelsForService, chatCompletion, buildExportArtifact, GLOBAL_ENV_PATH, } from "@actalk/inkos-core";
5
+ import { StateManager, PipelineRunner, createLLMClient, createLogger, createInteractionToolsFromDeps, computeAnalytics, loadProjectConfig, loadProjectSession, processProjectInteractionRequest, resolveSessionActiveBook, listBookSessions, loadBookSession, appendManualSessionMessages, createAndPersistBookSession, renameBookSession, deleteBookSession, migrateBookSession, SessionAlreadyMigratedError, runAgentSession, buildAgentSystemPrompt, resolveServicePreset, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, listModelsForService, getAllEndpoints, probeModelsFromUpstream, fetchWithProxy, chatCompletion, buildExportArtifact, GLOBAL_ENV_PATH, } from "@actalk/inkos-core";
6
6
  import { access, readFile, readdir, writeFile } from "node:fs/promises";
7
- import { join } from "node:path";
7
+ import { isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { isSafeBookId } from "./safety.js";
9
9
  import { ApiError } from "./errors.js";
10
10
  import { buildStudioBookConfig } from "./book-create.js";
@@ -49,6 +49,43 @@ function summarizeResult(result) {
49
49
  }
50
50
  return String(result).slice(0, 200);
51
51
  }
52
+ const NON_TEXT_MODEL_ID_PARTS = [
53
+ "image",
54
+ "embedding",
55
+ "embed",
56
+ "rerank",
57
+ "tts",
58
+ "speech",
59
+ "audio",
60
+ "moderation",
61
+ ];
62
+ function isTextChatModelId(modelId) {
63
+ const normalized = modelId.trim().toLowerCase();
64
+ if (!normalized)
65
+ return false;
66
+ return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
67
+ }
68
+ function filterTextChatModels(models) {
69
+ return models.filter((model) => isTextChatModelId(model.id));
70
+ }
71
+ function normalizeApiBookId(value, fieldName) {
72
+ if (value === undefined || value === null)
73
+ return null;
74
+ if (typeof value !== "string") {
75
+ throw new ApiError(400, "INVALID_BOOK_ID", `${fieldName} must be a string`);
76
+ }
77
+ const bookId = value.trim();
78
+ if (!bookId) {
79
+ throw new ApiError(400, "INVALID_BOOK_ID", `${fieldName} cannot be blank`);
80
+ }
81
+ if (!isSafeBookId(bookId)) {
82
+ throw new ApiError(400, "INVALID_BOOK_ID", `Invalid ${fieldName}: "${bookId}"`);
83
+ }
84
+ return bookId;
85
+ }
86
+ function nonTextModelMessage(modelId) {
87
+ return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
88
+ }
52
89
  function extractToolError(result) {
53
90
  if (typeof result === "string")
54
91
  return result.slice(0, 500);
@@ -64,6 +101,44 @@ function extractToolError(result) {
64
101
  }
65
102
  return String(result).slice(0, 500);
66
103
  }
104
+ function isLikelyFailedToolResult(exec) {
105
+ if (exec.status === "error")
106
+ return true;
107
+ const text = `${exec.error ?? ""}\n${exec.result ?? ""}`.toLowerCase();
108
+ return /\bfailed\b|\berror\b|失败|异常|出错/.test(text);
109
+ }
110
+ function hasSuccessfulSubAgentExec(execs, agent) {
111
+ return execs.some((exec) => exec.tool === "sub_agent"
112
+ && exec.agent === agent
113
+ && exec.status === "completed"
114
+ && !isLikelyFailedToolResult(exec));
115
+ }
116
+ function isWriteNextInstruction(instruction) {
117
+ const trimmed = instruction.trim();
118
+ return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
119
+ || /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
120
+ }
121
+ function looksLikeBookCreatedClaim(responseText) {
122
+ return /(?:已|已经|成功).{0,12}(?:创建|建书|初始化|保存).{0,12}(?:作品|书|书籍|文件夹)?/.test(responseText)
123
+ || /\b(?:created|initiali[sz]ed|saved)\b.{0,40}\b(?:book|project|novel)\b/i.test(responseText);
124
+ }
125
+ function validateAgentActionExecution(args) {
126
+ const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
127
+ if (failedExec) {
128
+ return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
129
+ }
130
+ if (args.agentBookId
131
+ && isWriteNextInstruction(args.instruction)
132
+ && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")) {
133
+ return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
134
+ }
135
+ if (!args.agentBookId
136
+ && looksLikeBookCreatedClaim(args.responseText)
137
+ && !resolveCreatedBookIdFromToolExecs(args.collectedToolExecs)) {
138
+ return "模型声称已创建作品,但没有实际调用建书工具,也没有生成作品文件。请补充书名/题材后重试,或换用支持工具调用的模型。";
139
+ }
140
+ return undefined;
141
+ }
67
142
  const subscribers = new Set();
68
143
  const bookCreateStatus = new Map();
69
144
  // 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
@@ -73,6 +148,45 @@ function broadcast(event, data) {
73
148
  handler(event, data);
74
149
  }
75
150
  }
151
+ function deriveBookIdFromTitle(title) {
152
+ return title
153
+ .trim()
154
+ .toLowerCase()
155
+ .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
156
+ .replace(/-+/g, "-")
157
+ .replace(/^-+|-+$/g, "")
158
+ .slice(0, 30);
159
+ }
160
+ function resolveArchitectBookIdFromArgs(args) {
161
+ if (!args || args.agent !== "architect" || args.revise === true)
162
+ return null;
163
+ if (typeof args.bookId === "string" && args.bookId.trim())
164
+ return args.bookId.trim();
165
+ if (typeof args.title === "string" && args.title.trim()) {
166
+ return deriveBookIdFromTitle(args.title) || null;
167
+ }
168
+ return null;
169
+ }
170
+ function resolveCreatedBookIdFromToolExecs(execs) {
171
+ for (let i = execs.length - 1; i >= 0; i -= 1) {
172
+ const exec = execs[i];
173
+ if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed")
174
+ continue;
175
+ const details = exec.details;
176
+ if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
177
+ return details.bookId.trim();
178
+ }
179
+ const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
180
+ if (fromArgs)
181
+ return fromArgs;
182
+ }
183
+ return null;
184
+ }
185
+ async function loadStudioBookListSummary(state, bookId) {
186
+ const book = await state.loadBookConfig(bookId);
187
+ const nextChapter = await state.getNextChapterNumber(bookId);
188
+ return { ...book, chaptersWritten: nextChapter - 1 };
189
+ }
76
190
  function isCustomServiceId(serviceId) {
77
191
  return serviceId === "custom" || serviceId.startsWith("custom:");
78
192
  }
@@ -86,7 +200,6 @@ function normalizeServiceEntry(serviceId, value) {
86
200
  name: decodeURIComponent(serviceId.slice("custom:".length)),
87
201
  ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
88
202
  ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
89
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
90
203
  ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
91
204
  ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
92
205
  };
@@ -97,7 +210,6 @@ function normalizeServiceEntry(serviceId, value) {
97
210
  ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
98
211
  ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
99
212
  ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
100
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
101
213
  ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
102
214
  ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
103
215
  };
@@ -105,7 +217,6 @@ function normalizeServiceEntry(serviceId, value) {
105
217
  return {
106
218
  service: serviceId,
107
219
  ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
108
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
109
220
  ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
110
221
  ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
111
222
  };
@@ -122,7 +233,6 @@ function normalizeServiceConfig(raw) {
122
233
  ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
123
234
  ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
124
235
  ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
125
- ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
126
236
  ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
127
237
  ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
128
238
  }));
@@ -193,6 +303,7 @@ async function readEnvConfigStatus(root) {
193
303
  project,
194
304
  global,
195
305
  effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
306
+ runtimeUsesEnv: false,
196
307
  };
197
308
  }
198
309
  async function resolveConfiguredServiceBaseUrl(root, serviceId, inlineBaseUrl) {
@@ -261,6 +372,8 @@ function buildModelCandidates(args) {
261
372
  push(args.envModel ?? undefined);
262
373
  for (const model of args.discoveredModels)
263
374
  push(model.id);
375
+ if (args.includeGenericFallbacks === false)
376
+ return candidates;
264
377
  push("gpt-5.4");
265
378
  push("gpt-4o");
266
379
  push("claude-sonnet-4-6");
@@ -268,16 +381,62 @@ function buildModelCandidates(args) {
268
381
  push("kimi-k2.5");
269
382
  return candidates;
270
383
  }
271
- async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey) {
384
+ function formatServiceProbeError(args) {
385
+ const rawDetail = args.error
386
+ .replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
387
+ .trim();
388
+ const upstreamDetail = rawDetail.includes("上游详情:")
389
+ ? rawDetail
390
+ : "";
391
+ const context = [
392
+ `服务商:${args.label ?? args.service}`,
393
+ `测试模型:${args.model ?? "未确定"}`,
394
+ `协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
395
+ `Base URL:${args.baseUrl}`,
396
+ ].join("\n");
397
+ if (args.service === "google") {
398
+ return [
399
+ "Google Gemini 测试连接失败。",
400
+ context,
401
+ "",
402
+ "请优先检查:",
403
+ "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
404
+ "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
405
+ "3. 当前地区/账号是否允许访问 Gemini API。",
406
+ "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
407
+ upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
408
+ ].filter(Boolean).join("\n");
409
+ }
410
+ if (args.service === "moonshot" || args.service === "kimiCodingPlan") {
411
+ return [
412
+ `${args.label ?? args.service} 测试连接失败。`,
413
+ context,
414
+ "",
415
+ "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
416
+ rawDetail ? `\n上游返回:${rawDetail}` : "",
417
+ ].filter(Boolean).join("\n");
418
+ }
419
+ return [
420
+ `${args.label ?? args.service} 测试连接失败。`,
421
+ context,
422
+ "",
423
+ "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
424
+ rawDetail ? `\n上游返回:${rawDetail}` : "",
425
+ ].filter(Boolean).join("\n");
426
+ }
427
+ async function fetchModelsFromServiceBaseUrl(serviceId, baseUrl, apiKey, proxyUrl) {
428
+ const endpoint = isCustomServiceId(serviceId)
429
+ ? undefined
430
+ : getAllEndpoints().find((ep) => ep.id === serviceId);
272
431
  const modelsBaseUrl = isCustomServiceId(serviceId)
273
432
  ? baseUrl
274
- : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl;
433
+ : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
275
434
  const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
276
435
  try {
277
- const res = await fetch(modelsUrl, {
436
+ const res = await fetchWithProxy(modelsUrl, {
278
437
  headers: { Authorization: `Bearer ${apiKey}` },
279
438
  signal: AbortSignal.timeout(10_000),
280
- });
439
+ }, proxyUrl);
281
440
  if (!res.ok) {
282
441
  const body = await res.text().catch(() => "");
283
442
  return {
@@ -308,7 +467,7 @@ async function probeServiceCapabilities(args) {
308
467
  ? envConfig.global.model
309
468
  : null;
310
469
  const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
311
- const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
470
+ const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
312
471
  if (modelsResponse.authFailed) {
313
472
  return {
314
473
  ok: false,
@@ -317,14 +476,28 @@ async function probeServiceCapabilities(args) {
317
476
  };
318
477
  }
319
478
  const discoveredModels = modelsResponse.models;
320
- // For services with knownModels, use their first model as top candidate — not the global default
479
+ // For bank services, probe with the service's own check model first — not the global default.
480
+ const endpoint = getAllEndpoints().find((ep) => ep.id === baseService);
321
481
  const preset = resolveServicePreset(baseService);
322
- const serviceFirstModel = preset?.knownModels?.[0];
482
+ const serviceFirstModel = endpoint?.checkModel
483
+ ?? preset?.knownModels?.[0]
484
+ ?? endpoint?.models.find((model) => model.enabled !== false)?.id;
485
+ const useEndpointCheckModel = !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
486
+ const configService = typeof llm.service === "string" ? llm.service : undefined;
487
+ const configModel = !useEndpointCheckModel && configService === args.service
488
+ ? typeof llm.defaultModel === "string"
489
+ ? llm.defaultModel
490
+ : typeof llm.model === "string"
491
+ ? llm.model
492
+ : undefined
493
+ : undefined;
494
+ const useCustomFallbacks = isCustomServiceId(args.service);
323
495
  const modelCandidates = buildModelCandidates({
324
496
  preferredModel: args.preferredModel ?? serviceFirstModel,
325
- configModel: typeof llm.defaultModel === "string" ? llm.defaultModel : typeof llm.model === "string" ? llm.model : undefined,
326
- envModel,
327
- discoveredModels,
497
+ configModel,
498
+ envModel: useCustomFallbacks ? envModel : undefined,
499
+ discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
500
+ includeGenericFallbacks: useCustomFallbacks,
328
501
  });
329
502
  if (modelCandidates.length === 0) {
330
503
  return {
@@ -346,6 +519,7 @@ async function probeServiceCapabilities(args) {
346
519
  temperature: 0.7,
347
520
  maxTokens: 2048,
348
521
  thinkingBudget: 0,
522
+ proxyUrl: args.proxyUrl,
349
523
  apiFormat: plan.apiFormat,
350
524
  stream: plan.stream,
351
525
  });
@@ -353,7 +527,12 @@ async function probeServiceCapabilities(args) {
353
527
  await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 });
354
528
  const models = discoveredModels.length > 0
355
529
  ? discoveredModels
356
- : preset?.knownModels?.map((id) => ({ id, name: id })) ?? [{ id: model, name: model }];
530
+ : endpoint?.models
531
+ .filter((m) => m.enabled !== false)
532
+ .filter((m) => isTextChatModelId(m.id))
533
+ .map((m) => ({ id: m.id, name: m.id }))
534
+ ?? preset?.knownModels?.map((id) => ({ id, name: id }))
535
+ ?? [{ id: model, name: model }];
357
536
  return {
358
537
  ok: true,
359
538
  models,
@@ -365,7 +544,15 @@ async function probeServiceCapabilities(args) {
365
544
  };
366
545
  }
367
546
  catch (error) {
368
- lastError = error instanceof Error ? error.message : String(error);
547
+ lastError = formatServiceProbeError({
548
+ service: baseService,
549
+ label: endpoint?.label ?? preset?.label,
550
+ baseUrl: args.baseUrl,
551
+ model,
552
+ apiFormat: plan.apiFormat,
553
+ stream: plan.stream,
554
+ error: error instanceof Error ? error.message : String(error),
555
+ });
369
556
  }
370
557
  }
371
558
  }
@@ -422,7 +609,7 @@ export function createStudioServer(initialConfig, root) {
422
609
  },
423
610
  };
424
611
  async function loadCurrentProjectConfig(options) {
425
- const freshConfig = await loadProjectConfig(root, options);
612
+ const freshConfig = await loadProjectConfig(root, { ...options, consumer: "studio" });
426
613
  cachedConfig = freshConfig;
427
614
  return freshConfig;
428
615
  }
@@ -446,6 +633,7 @@ export function createStudioServer(initialConfig, root) {
446
633
  model: overrides?.model ?? currentConfig.llm.model,
447
634
  projectRoot: root,
448
635
  defaultLLMConfig: currentConfig.llm,
636
+ foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
449
637
  modelOverrides: currentConfig.modelOverrides,
450
638
  notifyChannels: currentConfig.notify,
451
639
  logger,
@@ -464,11 +652,7 @@ export function createStudioServer(initialConfig, root) {
464
652
  // --- Books ---
465
653
  app.get("/api/v1/books", async (c) => {
466
654
  const bookIds = await state.listBooks();
467
- const books = await Promise.all(bookIds.map(async (id) => {
468
- const book = await state.loadBookConfig(id);
469
- const nextChapter = await state.getNextChapterNumber(id);
470
- return { ...book, chaptersWritten: nextChapter - 1 };
471
- }));
655
+ const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
472
656
  return c.json({ books });
473
657
  });
474
658
  app.get("/api/v1/books/:id", async (c) => {
@@ -529,10 +713,11 @@ export function createStudioServer(initialConfig, root) {
529
713
  targetChapters: body.targetChapters,
530
714
  },
531
715
  tools,
532
- }).then((result) => {
716
+ }).then(async (result) => {
533
717
  const createdBookId = result.details?.bookId ?? result.session.activeBookId ?? bookId;
718
+ const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
534
719
  bookCreateStatus.delete(createdBookId);
535
- broadcast("book:created", { bookId: createdBookId });
720
+ broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
536
721
  }, (e) => {
537
722
  const error = e instanceof Error ? e.message : String(e);
538
723
  bookCreateStatus.set(bookId, { status: "error", error });
@@ -589,26 +774,97 @@ export function createStudioServer(initialConfig, root) {
589
774
  }
590
775
  });
591
776
  // --- Truth files ---
592
- const TRUTH_FILES = [
777
+ // Flat-file whitelist — the pre-Phase-5 story root files plus dev's legacy
778
+ // editor targets (author_intent / current_focus / volume_outline).
779
+ //
780
+ // Phase 5 cleanup #3 moved the authoritative YAML frontmatter + outline prose
781
+ // into story/outline/ and character sheets into story/roles/. `story_bible.md`
782
+ // and `book_rules.md` now exist only as compat pointer shims — we still allow
783
+ // reading them so legacy books keep rendering, but the server-side writer
784
+ // (write_truth_file) no longer accepts them as edit targets.
785
+ const TRUTH_FLAT_FILES = [
593
786
  "author_intent.md", "current_focus.md",
594
- "story_bible.md", "volume_outline.md", "current_state.md",
787
+ "story_bible.md", "book_rules.md", "volume_outline.md", "current_state.md",
595
788
  "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
596
789
  "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
597
- "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
790
+ "style_guide.md", "parent_canon.md", "fanfic_canon.md",
598
791
  ];
599
- app.get("/api/v1/books/:id/truth/:file", async (c) => {
600
- const id = c.req.param("id");
792
+ // Authoritative Phase 5 paths — prose outline + role sheets live under
793
+ // dedicated subdirectories of story/. The full path (relative to story/) is
794
+ // matched literally here. `节奏原则.md` / `rhythm_principles.md` is optional
795
+ // after Phase 5 consolidation (rhythm lives in volume_map's closing paragraph);
796
+ // the entries stay whitelisted for legacy books and manual overrides.
797
+ const TRUTH_OUTLINE_FILES = [
798
+ "outline/story_frame.md",
799
+ "outline/volume_map.md",
800
+ "outline/节奏原则.md",
801
+ "outline/rhythm_principles.md",
802
+ ];
803
+ // Pointer shims that the runtime no longer treats as authoritative. The
804
+ // GET handler tags them with `legacy: true` so the UI can surface that the
805
+ // edits won't land where the user expects.
806
+ const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
807
+ /**
808
+ * Validate a requested truth-file path:
809
+ * 1. Must be one of the declared flat files, an outline/* allow-listed
810
+ * entry, or a roles/**\/*.md file under 主要角色/ | 次要角色/.
811
+ * 2. Must resolve to a path inside bookDir/story/ (no `..`, no absolute
812
+ * paths, no traversal via the tier-name segment).
813
+ */
814
+ function resolveTruthFilePath(bookDir, file) {
815
+ // Reject absolute paths, traversal, null bytes outright.
816
+ if (!file || file.includes("\0") || isAbsolute(file) || file.includes("..")) {
817
+ return null;
818
+ }
819
+ // Phase hotfix 3: accept both Chinese and English locale role dirs so
820
+ // English-layout books (roles/major, roles/minor) are reachable through
821
+ // Studio. The runtime reader (utils/outline-paths.ts:75) already scans
822
+ // both — Studio used to drop English books to read-only.
823
+ const allowed = TRUTH_FLAT_FILES.includes(file)
824
+ || TRUTH_OUTLINE_FILES.includes(file)
825
+ || /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/.test(file);
826
+ if (!allowed)
827
+ return null;
828
+ const storyDir = resolve(bookDir, "story");
829
+ const resolved = resolve(storyDir, file);
830
+ const relativePath = relative(storyDir, resolved);
831
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
832
+ return null;
833
+ }
834
+ return resolved;
835
+ }
836
+ async function fileExists(path) {
837
+ try {
838
+ await access(path);
839
+ return true;
840
+ }
841
+ catch {
842
+ return false;
843
+ }
844
+ }
845
+ // Use `:file{.+}` wildcard so nested paths (outline/..., roles/.../...) match.
846
+ app.get("/api/v1/books/:id/truth/:file{.+}", async (c) => {
601
847
  const file = c.req.param("file");
602
- if (!TRUTH_FILES.includes(file)) {
848
+ const id = c.req.param("id");
849
+ const bookDir = state.bookDir(id);
850
+ const resolved = resolveTruthFilePath(bookDir, file);
851
+ if (!resolved) {
603
852
  return c.json({ error: "Invalid truth file" }, 400);
604
853
  }
605
- const bookDir = state.bookDir(id);
854
+ // Phase 5: new-layout books keep the authoritative prose under outline/.
855
+ // A legacy book may only have story_bible.md / book_rules.md on disk —
856
+ // we still serve those for read-only display, but flag them so the UI
857
+ // can warn users their edits won't reach the runtime.
858
+ // Hotfix: only tag as legacy when the book actually HAS the new layout.
859
+ // Pre-Phase-5 books use story_bible/book_rules as the authoritative source.
860
+ const { isNewLayoutBook } = await import("@actalk/inkos-core");
861
+ const legacy = LEGACY_SHIM_FILES.has(file) && await isNewLayoutBook(bookDir);
606
862
  try {
607
- const content = await readFile(join(bookDir, "story", file), "utf-8");
608
- return c.json({ file, content });
863
+ const content = await readFile(resolved, "utf-8");
864
+ return c.json({ file, content, ...(legacy ? { legacy: true } : {}) });
609
865
  }
610
866
  catch {
611
- return c.json({ file, content: null });
867
+ return c.json({ file, content: null, ...(legacy ? { legacy: true } : {}) });
612
868
  }
613
869
  });
614
870
  // --- Analytics ---
@@ -691,6 +947,7 @@ export function createStudioServer(initialConfig, root) {
691
947
  stream.writeSSE({ event, data: JSON.stringify(data) });
692
948
  };
693
949
  subscribers.add(handler);
950
+ await stream.writeSSE({ event: "ping", data: "" });
694
951
  // Keep alive
695
952
  const keepAlive = setInterval(() => {
696
953
  stream.writeSSE({ event: "ping", data: "" });
@@ -706,19 +963,14 @@ export function createStudioServer(initialConfig, root) {
706
963
  // --- Model discovery ---
707
964
  app.get("/api/v1/services", async (c) => {
708
965
  const secrets = await loadSecrets(root);
709
- const SERVICE_KEYS = [
710
- "openai", "anthropic", "deepseek", "moonshot", "minimax",
711
- "bailian", "zhipu", "siliconflow", "ppio", "openrouter", "ollama",
712
- ];
713
- // Fast: only check connection status from secrets, no external API calls
714
- const services = SERVICE_KEYS.map((key) => {
715
- const preset = resolveServicePreset(key);
716
- return {
717
- service: key,
718
- label: preset?.label ?? key,
719
- connected: Boolean(secrets.services[key]?.apiKey),
720
- };
721
- });
966
+ const endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
967
+ // Fast: only check connection status from secrets, no external API calls.
968
+ const services = endpoints.map((ep) => ({
969
+ service: ep.id,
970
+ label: ep.label,
971
+ group: ep.group,
972
+ connected: Boolean(secrets.services[ep.id]?.apiKey),
973
+ }));
722
974
  // Add custom services from inkos.json
723
975
  try {
724
976
  const config = await loadRawConfig(root);
@@ -728,6 +980,7 @@ export function createStudioServer(initialConfig, root) {
728
980
  services.push({
729
981
  service: secretKey,
730
982
  label: svc.name ?? "Custom",
983
+ group: undefined,
731
984
  connected: Boolean(secrets.services[secretKey]?.apiKey),
732
985
  });
733
986
  }
@@ -743,8 +996,10 @@ export function createStudioServer(initialConfig, root) {
743
996
  const envConfig = await readEnvConfigStatus(root);
744
997
  return c.json({
745
998
  services,
999
+ service: typeof llm.service === "string" ? llm.service : null,
746
1000
  defaultModel: llm.defaultModel ?? null,
747
- configSource: normalizeConfigSource(llm.configSource),
1001
+ configSource: "studio",
1002
+ storedConfigSource: normalizeConfigSource(llm.configSource),
748
1003
  envConfig,
749
1004
  });
750
1005
  });
@@ -761,6 +1016,11 @@ export function createStudioServer(initialConfig, root) {
761
1016
  if (body.defaultModel !== undefined) {
762
1017
  llm.defaultModel = body.defaultModel;
763
1018
  }
1019
+ if (body.configSource === "env") {
1020
+ return c.json({
1021
+ error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
1022
+ }, 400);
1023
+ }
764
1024
  if (body.configSource !== undefined) {
765
1025
  llm.configSource = normalizeConfigSource(body.configSource);
766
1026
  }
@@ -780,6 +1040,8 @@ export function createStudioServer(initialConfig, root) {
780
1040
  if (!resolvedBaseUrl) {
781
1041
  return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
782
1042
  }
1043
+ const rawConfig = await loadRawConfig(root).catch(() => ({}));
1044
+ const llm = rawConfig.llm ?? {};
783
1045
  const probe = await probeServiceCapabilities({
784
1046
  root,
785
1047
  service,
@@ -787,9 +1049,21 @@ export function createStudioServer(initialConfig, root) {
787
1049
  baseUrl: resolvedBaseUrl,
788
1050
  preferredApiFormat: apiFormat,
789
1051
  preferredStream: stream,
1052
+ proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
790
1053
  });
1054
+ // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
1055
+ const probeStatus = {
1056
+ ok: probe.ok,
1057
+ models: probe.models?.length ?? 0,
1058
+ ...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
1059
+ };
791
1060
  if (!probe.ok) {
792
- return c.json({ ok: false, error: probe.error ?? "连接失败" }, 400);
1061
+ return c.json({
1062
+ ok: false,
1063
+ error: probe.error ?? "连接失败",
1064
+ probe: probeStatus,
1065
+ chat: null,
1066
+ }, 400);
793
1067
  }
794
1068
  return c.json({
795
1069
  ok: true,
@@ -802,6 +1076,9 @@ export function createStudioServer(initialConfig, root) {
802
1076
  baseUrl: probe.baseUrl,
803
1077
  modelsSource: probe.modelsSource,
804
1078
  },
1079
+ // B12 新字段:两步验证状态
1080
+ probe: probeStatus,
1081
+ chat: null, // probeServiceCapabilities 本身只做 probe,chat hello 在 Studio 的 follow-up 调用里单独触发
805
1082
  });
806
1083
  });
807
1084
  app.put("/api/v1/services/:service/secret", async (c) => {
@@ -824,14 +1101,57 @@ export function createStudioServer(initialConfig, root) {
824
1101
  apiKey: secrets.services[service]?.apiKey ?? "",
825
1102
  });
826
1103
  });
1104
+ app.get("/api/v1/services/models", async (c) => {
1105
+ const secrets = await loadSecrets(root);
1106
+ const endpoints = getAllEndpoints()
1107
+ .filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
1108
+ const groups = endpoints.map((ep) => ({
1109
+ service: ep.id,
1110
+ label: ep.label,
1111
+ models: ep.models
1112
+ .filter((m) => m.enabled !== false)
1113
+ .filter((m) => isTextChatModelId(m.id))
1114
+ .map((m) => ({
1115
+ id: m.id,
1116
+ name: m.id,
1117
+ ...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
1118
+ ...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
1119
+ })),
1120
+ }));
1121
+ return c.json({ groups });
1122
+ });
1123
+ app.get("/api/v1/services/models/custom", async (c) => {
1124
+ const secrets = await loadSecrets(root);
1125
+ let config = {};
1126
+ try {
1127
+ config = await loadRawConfig(root);
1128
+ }
1129
+ catch {
1130
+ // no config file
1131
+ }
1132
+ const customs = normalizeServiceConfig(config.llm?.services)
1133
+ .filter((s) => s.service === "custom")
1134
+ .map((s) => ({
1135
+ id: `custom:${s.name ?? "Custom"}`,
1136
+ baseUrl: s.baseUrl ?? "",
1137
+ label: s.name ?? "Custom",
1138
+ }))
1139
+ .filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
1140
+ const groups = await Promise.all(customs.map(async (s) => ({
1141
+ service: s.id,
1142
+ label: s.label,
1143
+ models: filterTextChatModels(await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000)),
1144
+ })));
1145
+ return c.json({ groups });
1146
+ });
827
1147
  app.get("/api/v1/services/:service/models", async (c) => {
828
1148
  const service = c.req.param("service");
829
1149
  const refresh = c.req.query("refresh") === "1";
830
- const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
1150
+ const secrets = await loadSecrets(root);
1151
+ const apiKey = c.req.query("apiKey") || secrets.services[service]?.apiKey || "";
831
1152
  // No key = no models
832
1153
  if (!apiKey)
833
1154
  return c.json({ models: [] });
834
- const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service);
835
1155
  const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
836
1156
  // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
837
1157
  const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
@@ -841,33 +1161,14 @@ export function createStudioServer(initialConfig, root) {
841
1161
  return c.json({ models: cached.models });
842
1162
  }
843
1163
  }
844
- // Fast path: services with knownModels return immediately
845
- if (preset?.knownModels && preset.knownModels.length > 0) {
846
- const models = preset.knownModels.map((id) => ({ id, name: id }));
847
- modelListCache.set(cacheKey, { models, at: Date.now() });
848
- return c.json({ models });
849
- }
850
- // Simple /models API call + fallback to pi-ai built-in list (no slow probe)
851
- if (!resolvedBaseUrl)
852
- return c.json({ models: [] });
853
- const modelsBase = preset?.modelsBaseUrl ?? resolvedBaseUrl;
854
- let models = [];
855
- try {
856
- const modelsUrl = modelsBase.replace(/\/$/, "") + "/models";
857
- const res = await fetch(modelsUrl, {
858
- headers: { Authorization: `Bearer ${apiKey}` },
859
- signal: AbortSignal.timeout(10_000),
860
- });
861
- if (res.ok) {
862
- const json = await res.json();
863
- models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
864
- }
865
- }
866
- catch { /* timeout or network error */ }
867
- if (models.length === 0) {
868
- const builtIn = await listModelsForService(service, apiKey);
869
- models = builtIn.map((m) => ({ id: m.id, name: m.name }));
870
- }
1164
+ // B13: listModelsForService live probe + bank 交叉,返回带元数据的 models
1165
+ const enriched = await listModelsForService(isCustomServiceId(service) ? "custom" : service, apiKey, isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined);
1166
+ const models = filterTextChatModels(enriched).map((m) => ({
1167
+ id: m.id,
1168
+ name: m.name,
1169
+ ...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
1170
+ ...(m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}),
1171
+ }));
871
1172
  modelListCache.set(cacheKey, { models, at: Date.now() });
872
1173
  return c.json({ models });
873
1174
  });
@@ -886,7 +1187,6 @@ export function createStudioServer(initialConfig, root) {
886
1187
  baseUrl: currentConfig.llm.baseUrl,
887
1188
  stream: currentConfig.llm.stream,
888
1189
  temperature: currentConfig.llm.temperature,
889
- maxTokens: currentConfig.llm.maxTokens,
890
1190
  });
891
1191
  });
892
1192
  // --- Config editing ---
@@ -900,9 +1200,6 @@ export function createStudioServer(initialConfig, root) {
900
1200
  if (updates.temperature !== undefined) {
901
1201
  existing.llm.temperature = updates.temperature;
902
1202
  }
903
- if (updates.maxTokens !== undefined) {
904
- existing.llm.maxTokens = updates.maxTokens;
905
- }
906
1203
  if (updates.stream !== undefined) {
907
1204
  existing.llm.stream = updates.stream;
908
1205
  }
@@ -922,13 +1219,52 @@ export function createStudioServer(initialConfig, root) {
922
1219
  const id = c.req.param("id");
923
1220
  const bookDir = state.bookDir(id);
924
1221
  const storyDir = join(bookDir, "story");
1222
+ async function listDir(subdir) {
1223
+ try {
1224
+ const entries = await readdir(join(storyDir, subdir));
1225
+ return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
1226
+ }
1227
+ catch {
1228
+ return [];
1229
+ }
1230
+ }
1231
+ // Hotfix: only tag shim files as legacy when the book has the new layout.
1232
+ const { isNewLayoutBook } = await import("@actalk/inkos-core");
1233
+ const newLayout = await isNewLayoutBook(bookDir);
1234
+ async function describe(relPath) {
1235
+ try {
1236
+ const content = await readFile(join(storyDir, relPath), "utf-8");
1237
+ const isShim = LEGACY_SHIM_FILES.has(relPath) && newLayout;
1238
+ const entry = isShim
1239
+ ? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
1240
+ : { name: relPath, size: content.length, preview: content.slice(0, 200) };
1241
+ return entry;
1242
+ }
1243
+ catch {
1244
+ return null;
1245
+ }
1246
+ }
925
1247
  try {
926
- const files = await readdir(storyDir);
927
- const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
928
- const result = await Promise.all(mdFiles.map(async (f) => {
929
- const content = await readFile(join(storyDir, f), "utf-8");
930
- return { name: f, size: content.length, preview: content.slice(0, 200) };
931
- }));
1248
+ // Flat story/ files (legacy + runtime logs)
1249
+ const flatFiles = (await listDir(".")).filter((f) => !f.startsWith("outline") && !f.startsWith("roles"));
1250
+ // Phase 5 outline/ files
1251
+ const outlineFiles = (await listDir("outline")).map((f) => `outline/${f}`);
1252
+ // Phase 5 roles/主要角色 + roles/次要角色, plus Phase hotfix 3
1253
+ // English-locale equivalents so en-language books are visible.
1254
+ const majorRolesZh = (await listDir("roles/主要角色")).map((f) => `roles/主要角色/${f}`);
1255
+ const minorRolesZh = (await listDir("roles/次要角色")).map((f) => `roles/次要角色/${f}`);
1256
+ const majorRolesEn = (await listDir("roles/major")).map((f) => `roles/major/${f}`);
1257
+ const minorRolesEn = (await listDir("roles/minor")).map((f) => `roles/minor/${f}`);
1258
+ const all = [
1259
+ ...flatFiles,
1260
+ ...outlineFiles,
1261
+ ...majorRolesZh,
1262
+ ...minorRolesZh,
1263
+ ...majorRolesEn,
1264
+ ...minorRolesEn,
1265
+ ];
1266
+ const described = await Promise.all(all.map(describe));
1267
+ const result = described.filter((x) => x !== null);
932
1268
  return c.json({ files: result });
933
1269
  }
934
1270
  catch {
@@ -1036,7 +1372,7 @@ export function createStudioServer(initialConfig, root) {
1036
1372
  });
1037
1373
  app.post("/api/v1/sessions", async (c) => {
1038
1374
  const body = await c.req.json().catch(() => ({}));
1039
- const bookId = body.bookId ?? null;
1375
+ const bookId = normalizeApiBookId(body.bookId, "bookId");
1040
1376
  const sessionId = body.sessionId;
1041
1377
  // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
1042
1378
  const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
@@ -1069,6 +1405,10 @@ export function createStudioServer(initialConfig, root) {
1069
1405
  if (!sessionId?.trim()) {
1070
1406
  throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
1071
1407
  }
1408
+ if (reqModel && !isTextChatModelId(reqModel)) {
1409
+ const message = nonTextModelMessage(reqModel);
1410
+ return c.json({ error: message, response: message }, 400);
1411
+ }
1072
1412
  broadcast("agent:start", { instruction, activeBookId, sessionId });
1073
1413
  try {
1074
1414
  // Load config + create LLM client (pipeline created after model resolution)
@@ -1079,11 +1419,35 @@ export function createStudioServer(initialConfig, root) {
1079
1419
  throw new ApiError(404, "SESSION_NOT_FOUND", `Session not found: ${sessionId}`);
1080
1420
  }
1081
1421
  let bookSession = loadedBookSession;
1422
+ const requestedActiveBookId = normalizeApiBookId(activeBookId, "activeBookId");
1423
+ const persistedBookId = normalizeApiBookId(bookSession.bookId, "session.bookId");
1424
+ if (requestedActiveBookId
1425
+ && persistedBookId
1426
+ && persistedBookId !== requestedActiveBookId) {
1427
+ throw new ApiError(409, "SESSION_BOOK_MISMATCH", `Session ${bookSession.sessionId} is bound to ${persistedBookId}, not ${requestedActiveBookId}`);
1428
+ }
1429
+ const agentBookId = requestedActiveBookId ?? persistedBookId;
1430
+ if (agentBookId) {
1431
+ try {
1432
+ await state.loadBookConfig(agentBookId);
1433
+ }
1434
+ catch {
1435
+ throw new ApiError(404, "BOOK_NOT_FOUND", `Book not found: ${agentBookId}`);
1436
+ }
1437
+ }
1082
1438
  const streamSessionId = loadedBookSession.sessionId;
1083
- // Build initial message context from persisted session
1084
- const initialMessages = bookSession.messages
1085
- .filter((m) => m.role === "user" || m.role === "assistant")
1086
- .map((m) => ({ role: m.role, content: m.content }));
1439
+ const titleBeforeRun = bookSession.title;
1440
+ let sessionTitleBroadcasted = false;
1441
+ const refreshBookSessionFromTranscript = async () => {
1442
+ const refreshed = await loadBookSession(root, bookSession.sessionId);
1443
+ if (refreshed) {
1444
+ bookSession = refreshed;
1445
+ }
1446
+ if (!sessionTitleBroadcasted && titleBeforeRun === null && bookSession.title) {
1447
+ broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
1448
+ sessionTitleBroadcasted = true;
1449
+ }
1450
+ };
1087
1451
  // Resolve model — multi-service resolution
1088
1452
  let resolvedModel;
1089
1453
  let resolvedApiKey;
@@ -1112,7 +1476,7 @@ export function createStudioServer(initialConfig, root) {
1112
1476
  const defaultModel = rawConfig.defaultModel;
1113
1477
  const servicesArr = normalizeServiceConfig(rawConfig.services);
1114
1478
  const firstService = servicesArr[0];
1115
- if (firstService?.service && defaultModel) {
1479
+ if (firstService?.service && defaultModel && isTextChatModelId(defaultModel)) {
1116
1480
  try {
1117
1481
  const resolved = await resolveServiceModel(serviceConfigKey(firstService), defaultModel, root, firstService.baseUrl, firstService.apiFormat);
1118
1482
  resolvedModel = resolved.model;
@@ -1128,9 +1492,10 @@ export function createStudioServer(initialConfig, root) {
1128
1492
  if (svcData?.apiKey) {
1129
1493
  try {
1130
1494
  const models = await listModelsForService(svcName, svcData.apiKey);
1131
- if (models.length > 0) {
1495
+ const textModels = filterTextChatModels(models);
1496
+ if (textModels.length > 0) {
1132
1497
  const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
1133
- const resolved = await resolveServiceModel(svcName, models[0].id, root, await resolveConfiguredServiceBaseUrl(root, svcName), configuredEntry?.apiFormat);
1498
+ const resolved = await resolveServiceModel(svcName, textModels[0].id, root, await resolveConfiguredServiceBaseUrl(root, svcName), configuredEntry?.apiFormat);
1134
1499
  resolvedModel = resolved.model;
1135
1500
  resolvedApiKey = resolved.apiKey;
1136
1501
  break;
@@ -1170,6 +1535,85 @@ export function createStudioServer(initialConfig, root) {
1170
1535
  currentConfig: config,
1171
1536
  sessionIdForSSE: bookSession.sessionId,
1172
1537
  }));
1538
+ if (agentBookId && isWriteNextInstruction(instruction)) {
1539
+ const toolCallId = `direct-writer-${Date.now().toString(36)}`;
1540
+ const toolArgs = { agent: "writer", bookId: agentBookId };
1541
+ broadcast("tool:start", {
1542
+ sessionId: streamSessionId,
1543
+ id: toolCallId,
1544
+ tool: "sub_agent",
1545
+ args: toolArgs,
1546
+ stages: PIPELINE_STAGES.writer,
1547
+ });
1548
+ try {
1549
+ const writeResult = await pipeline.writeNextChapter(agentBookId);
1550
+ const responseText = [
1551
+ `已为 ${agentBookId} 完成第 ${writeResult.chapterNumber} 章`,
1552
+ writeResult.title ? `《${writeResult.title}》` : "",
1553
+ `,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
1554
+ ].join("");
1555
+ const toolResult = {
1556
+ content: [{ type: "text", text: responseText }],
1557
+ details: {
1558
+ kind: "chapter_written",
1559
+ bookId: agentBookId,
1560
+ chapterNumber: writeResult.chapterNumber,
1561
+ title: writeResult.title,
1562
+ wordCount: writeResult.wordCount,
1563
+ status: writeResult.status,
1564
+ },
1565
+ };
1566
+ broadcast("tool:end", {
1567
+ sessionId: streamSessionId,
1568
+ id: toolCallId,
1569
+ tool: "sub_agent",
1570
+ result: toolResult,
1571
+ isError: false,
1572
+ });
1573
+ await appendManualSessionMessages(root, bookSession.sessionId, [{
1574
+ role: "assistant",
1575
+ content: [{ type: "text", text: responseText }],
1576
+ api: "anthropic-messages",
1577
+ provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
1578
+ model: reqModel ?? config.llm.model,
1579
+ usage: {
1580
+ input: 0,
1581
+ output: 0,
1582
+ cacheRead: 0,
1583
+ cacheWrite: 0,
1584
+ totalTokens: 0,
1585
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1586
+ },
1587
+ stopReason: "toolUse",
1588
+ timestamp: Date.now(),
1589
+ }], instruction);
1590
+ await refreshBookSessionFromTranscript();
1591
+ broadcast("agent:complete", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId });
1592
+ return c.json({
1593
+ response: responseText,
1594
+ session: {
1595
+ sessionId: bookSession.sessionId,
1596
+ activeBookId: agentBookId,
1597
+ },
1598
+ });
1599
+ }
1600
+ catch (error) {
1601
+ const message = error instanceof Error ? error.message : String(error);
1602
+ const toolResult = { content: [{ type: "text", text: message }] };
1603
+ broadcast("tool:end", {
1604
+ sessionId: streamSessionId,
1605
+ id: toolCallId,
1606
+ tool: "sub_agent",
1607
+ result: toolResult,
1608
+ isError: true,
1609
+ });
1610
+ broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, error: message });
1611
+ return c.json({
1612
+ error: { code: "AGENT_ACTION_FAILED", message },
1613
+ response: message,
1614
+ }, 502);
1615
+ }
1616
+ }
1173
1617
  // Run pi-agent session
1174
1618
  const collectedToolExecs = [];
1175
1619
  const result = await runAgentSession({
@@ -1177,7 +1621,7 @@ export function createStudioServer(initialConfig, root) {
1177
1621
  apiKey: agentApiKey,
1178
1622
  pipeline,
1179
1623
  projectRoot: root,
1180
- bookId: activeBookId ?? null,
1624
+ bookId: agentBookId,
1181
1625
  sessionId: bookSession.sessionId,
1182
1626
  language: config.language ?? "zh",
1183
1627
  onEvent: (event) => {
@@ -1212,6 +1656,16 @@ export function createStudioServer(initialConfig, root) {
1212
1656
  : undefined,
1213
1657
  startedAt: Date.now(),
1214
1658
  });
1659
+ if (!agentBookId && event.toolName === "sub_agent" && agent === "architect") {
1660
+ const bookId = resolveArchitectBookIdFromArgs(args);
1661
+ if (bookId) {
1662
+ const title = typeof args?.title === "string" && args.title.trim()
1663
+ ? args.title.trim()
1664
+ : bookId;
1665
+ bookCreateStatus.set(bookId, { status: "creating" });
1666
+ broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
1667
+ }
1668
+ }
1215
1669
  broadcast("tool:start", {
1216
1670
  sessionId: streamSessionId,
1217
1671
  id: event.toolCallId,
@@ -1237,6 +1691,18 @@ export function createStudioServer(initialConfig, root) {
1237
1691
  exec.error = extractToolError(event.result);
1238
1692
  else
1239
1693
  exec.result = summarizeResult(event.result);
1694
+ exec.details = event.result?.details;
1695
+ if (event.isError &&
1696
+ !agentBookId &&
1697
+ exec.tool === "sub_agent" &&
1698
+ exec.agent === "architect") {
1699
+ const bookId = resolveArchitectBookIdFromArgs(exec.args);
1700
+ if (bookId) {
1701
+ const error = exec.error ?? "Book creation failed";
1702
+ bookCreateStatus.set(bookId, { status: "error", error });
1703
+ broadcast("book:error", { bookId, sessionId: streamSessionId, error });
1704
+ }
1705
+ }
1240
1706
  }
1241
1707
  broadcast("tool:end", {
1242
1708
  sessionId: streamSessionId,
@@ -1247,35 +1713,61 @@ export function createStudioServer(initialConfig, root) {
1247
1713
  });
1248
1714
  }
1249
1715
  },
1250
- }, instruction, initialMessages);
1251
- // Persist user + assistant messages to BookSession
1252
- bookSession = appendBookSessionMessage(bookSession, {
1253
- role: "user",
1254
- content: instruction,
1255
- timestamp: Date.now(),
1256
- });
1257
- // 第一条用户消息就是 session 的标题:如果 title 还是 null,用消息内容(单行、≤20字)写入。
1258
- // 后续消息不覆盖;用户手动改名通过 renameBookSession 覆盖。
1259
- if (bookSession.title === null) {
1260
- const oneLine = instruction.trim().replace(/\s+/g, " ");
1261
- const title = oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
1262
- if (title) {
1263
- bookSession = { ...bookSession, title };
1264
- broadcast("session:title", { sessionId: bookSession.sessionId, title });
1265
- }
1266
- }
1716
+ }, instruction);
1267
1717
  if (result.responseText) {
1268
- const lastAssistant = result.messages?.filter((m) => m.role === "assistant").pop();
1269
- const thinking = lastAssistant?.thinking;
1270
- bookSession = appendBookSessionMessage(bookSession, {
1271
- role: "assistant",
1272
- content: result.responseText,
1273
- ...(thinking ? { thinking } : {}),
1274
- ...(collectedToolExecs.length > 0 ? { toolExecutions: collectedToolExecs } : {}),
1275
- timestamp: Date.now() + 1,
1718
+ const actionExecutionError = validateAgentActionExecution({
1719
+ instruction,
1720
+ agentBookId,
1721
+ responseText: result.responseText,
1722
+ collectedToolExecs,
1276
1723
  });
1724
+ if (actionExecutionError) {
1725
+ return c.json({
1726
+ error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
1727
+ response: actionExecutionError,
1728
+ }, 502);
1729
+ }
1277
1730
  }
1731
+ let broadcastedCreatedBookId = null;
1732
+ const finalizeCreatedBook = async () => {
1733
+ if (agentBookId)
1734
+ return null;
1735
+ const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
1736
+ if (!createdBookId)
1737
+ return null;
1738
+ if (broadcastedCreatedBookId === createdBookId)
1739
+ return createdBookId;
1740
+ try {
1741
+ const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
1742
+ if (migratedSession) {
1743
+ bookSession = migratedSession;
1744
+ }
1745
+ }
1746
+ catch (e) {
1747
+ if (!(e instanceof SessionAlreadyMigratedError)) {
1748
+ throw e;
1749
+ }
1750
+ }
1751
+ const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
1752
+ bookCreateStatus.delete(createdBookId);
1753
+ broadcast("book:created", {
1754
+ bookId: createdBookId,
1755
+ sessionId: bookSession.sessionId,
1756
+ ...(book ? { book } : {}),
1757
+ });
1758
+ broadcastedCreatedBookId = createdBookId;
1759
+ return createdBookId;
1760
+ };
1278
1761
  if (!result.responseText) {
1762
+ if (result.errorMessage) {
1763
+ if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
1764
+ await finalizeCreatedBook();
1765
+ }
1766
+ return c.json({
1767
+ error: { code: "AGENT_LLM_ERROR", message: result.errorMessage },
1768
+ response: result.errorMessage,
1769
+ }, 502);
1770
+ }
1279
1771
  try {
1280
1772
  const fallbackClient = createLLMClient({
1281
1773
  ...config.llm,
@@ -1287,19 +1779,47 @@ export function createStudioServer(initialConfig, root) {
1287
1779
  ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
1288
1780
  });
1289
1781
  const fallback = await chatCompletion(fallbackClient, reqModel ?? config.llm.model, [
1290
- { role: "system", content: buildAgentSystemPrompt(activeBookId ?? null, config.language ?? "zh") },
1782
+ { role: "system", content: buildAgentSystemPrompt(agentBookId, config.language ?? "zh") },
1291
1783
  { role: "user", content: instruction },
1292
1784
  ], { maxTokens: 256 });
1293
1785
  if (fallback.content?.trim()) {
1294
- bookSession = appendBookSessionMessage(bookSession, {
1295
- role: "assistant",
1296
- content: fallback.content,
1297
- timestamp: Date.now() + 1,
1786
+ const actionExecutionError = validateAgentActionExecution({
1787
+ instruction,
1788
+ agentBookId,
1789
+ responseText: fallback.content,
1790
+ collectedToolExecs,
1298
1791
  });
1299
- await persistBookSession(root, bookSession);
1792
+ if (actionExecutionError) {
1793
+ return c.json({
1794
+ error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
1795
+ response: actionExecutionError,
1796
+ }, 502);
1797
+ }
1798
+ await appendManualSessionMessages(root, bookSession.sessionId, [{
1799
+ role: "assistant",
1800
+ content: [{ type: "text", text: fallback.content }],
1801
+ api: "anthropic-messages",
1802
+ provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
1803
+ model: reqModel ?? config.llm.model,
1804
+ usage: {
1805
+ input: 0,
1806
+ output: 0,
1807
+ cacheRead: 0,
1808
+ cacheWrite: 0,
1809
+ totalTokens: 0,
1810
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1811
+ },
1812
+ stopReason: "stop",
1813
+ timestamp: Date.now(),
1814
+ }], instruction);
1815
+ await refreshBookSessionFromTranscript();
1816
+ const createdBookId = await finalizeCreatedBook();
1300
1817
  return c.json({
1301
1818
  response: fallback.content,
1302
- session: { sessionId: bookSession.sessionId },
1819
+ session: {
1820
+ sessionId: bookSession.sessionId,
1821
+ ...(createdBookId ? { activeBookId: createdBookId } : {}),
1822
+ },
1303
1823
  });
1304
1824
  }
1305
1825
  }
@@ -1320,39 +1840,26 @@ export function createStudioServer(initialConfig, root) {
1320
1840
  }
1321
1841
  catch (probeError) {
1322
1842
  const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
1843
+ if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
1844
+ await finalizeCreatedBook();
1845
+ }
1323
1846
  return c.json({
1324
1847
  error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
1325
1848
  response: probeMessage,
1326
1849
  }, 502);
1327
1850
  }
1328
1851
  const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
1852
+ if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
1853
+ await finalizeCreatedBook();
1854
+ }
1329
1855
  return c.json({
1330
1856
  error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
1331
1857
  response: emptyMessage,
1332
1858
  }, 502);
1333
1859
  }
1334
- await persistBookSession(root, bookSession);
1860
+ await refreshBookSessionFromTranscript();
1861
+ await finalizeCreatedBook();
1335
1862
  broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
1336
- // If a sub_agent created a new book during this session, broadcast book:created
1337
- // so the sidebar refreshes.
1338
- if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) {
1339
- const books = await state.listBooks();
1340
- const latestBook = books.at(-1);
1341
- if (latestBook) {
1342
- try {
1343
- const migratedSession = await migrateBookSession(root, bookSession.sessionId, latestBook);
1344
- if (migratedSession) {
1345
- bookSession = migratedSession;
1346
- }
1347
- }
1348
- catch (e) {
1349
- if (!(e instanceof SessionAlreadyMigratedError)) {
1350
- throw e;
1351
- }
1352
- }
1353
- broadcast("book:created", { bookId: latestBook, sessionId: bookSession.sessionId });
1354
- }
1355
- }
1356
1863
  return c.json({
1357
1864
  response: result.responseText,
1358
1865
  session: {
@@ -1596,17 +2103,28 @@ export function createStudioServer(initialConfig, root) {
1596
2103
  }
1597
2104
  });
1598
2105
  // --- Truth file edit ---
1599
- app.put("/api/v1/books/:id/truth/:file", async (c) => {
2106
+ app.put("/api/v1/books/:id/truth/:file{.+}", async (c) => {
1600
2107
  const id = c.req.param("id");
1601
2108
  const file = c.req.param("file");
1602
- if (!TRUTH_FILES.includes(file)) {
2109
+ const bookDir = state.bookDir(id);
2110
+ const resolved = resolveTruthFilePath(bookDir, file);
2111
+ if (!resolved) {
1603
2112
  return c.json({ error: "Invalid truth file" }, 400);
1604
2113
  }
2114
+ // Legacy pointer shims are read-only in new-layout books: writing
2115
+ // story_bible.md or book_rules.md does nothing at runtime (the pipeline
2116
+ // reads outline/ instead). For pre-Phase-5 books these ARE authoritative.
2117
+ if (LEGACY_SHIM_FILES.has(file)) {
2118
+ const { isNewLayoutBook } = await import("@actalk/inkos-core");
2119
+ if (await isNewLayoutBook(bookDir)) {
2120
+ return c.json({ error: "Legacy compat shim; edit outline/story_frame.md instead" }, 400);
2121
+ }
2122
+ }
1605
2123
  const { content } = await c.req.json();
1606
- const bookDir = state.bookDir(id);
1607
2124
  const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
1608
- await mkdirFs(join(bookDir, "story"), { recursive: true });
1609
- await writeFileFs(join(bookDir, "story", file), content, "utf-8");
2125
+ const { dirname: dirnameFs } = await import("node:path");
2126
+ await mkdirFs(dirnameFs(resolved), { recursive: true });
2127
+ await writeFileFs(resolved, content, "utf-8");
1610
2128
  return c.json({ ok: true });
1611
2129
  });
1612
2130
  // =============================================
@@ -1818,6 +2336,8 @@ export function createStudioServer(initialConfig, root) {
1818
2336
  app.post("/api/v1/books/:id/style/import", async (c) => {
1819
2337
  const id = c.req.param("id");
1820
2338
  const { text, sourceName } = await c.req.json();
2339
+ if (!text?.trim())
2340
+ return c.json({ error: "text is required" }, 400);
1821
2341
  broadcast("style:start", { bookId: id });
1822
2342
  try {
1823
2343
  const pipeline = new PipelineRunner(await buildPipelineConfig());
@@ -1974,6 +2494,7 @@ export function createStudioServer(initialConfig, root) {
1974
2494
  preferredApiFormat: currentConfig.llm.apiFormat,
1975
2495
  preferredStream: currentConfig.llm.stream,
1976
2496
  preferredModel: currentConfig.llm.model,
2497
+ proxyUrl: currentConfig.llm.proxyUrl,
1977
2498
  });
1978
2499
  checks.llmConnected = probe.ok;
1979
2500
  }
@@ -1984,7 +2505,7 @@ export function createStudioServer(initialConfig, root) {
1984
2505
  }
1985
2506
  // --- Standalone runner ---
1986
2507
  export async function startStudioServer(root, port = 4567, options) {
1987
- const config = await loadProjectConfig(root, { requireApiKey: false });
2508
+ const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
1988
2509
  const app = createStudioServer(config, root);
1989
2510
  // Serve frontend static files — single process for API + frontend
1990
2511
  if (options?.staticDir) {