@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.
- package/dist/api/safety.d.ts +2 -2
- package/dist/api/safety.d.ts.map +1 -1
- package/dist/api/safety.js +3 -9
- package/dist/api/safety.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +679 -158
- package/dist/api/server.js.map +1 -1
- package/dist/assets/{_baseUniq-D1lvsUv0.js → _baseUniq-DObXPjDJ.js} +1 -1
- package/dist/assets/{arc-DWfC7Ioo.js → arc-BdlZFnEp.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-hK0Bbf6b.js → architectureDiagram-Q4EWVU46-DWIamVb1.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CiSHn5nQ.js → blockDiagram-DXYQGD6D-B15h8JDl.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-C2fRvv6n.js → c4Diagram-AHTNJAMY-CLWyVUi5.js} +1 -1
- package/dist/assets/channel-CnRwuGVB.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-cV6Ky6lS.js → chunk-4BX2VUAB-CoreBbcY.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DRikaS1A.js → chunk-4TB4RGXK-hpWBtrjE.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Dyk-VVkW.js → chunk-55IACEB6-CAWFhQRP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DOWoSXk1.js → chunk-EDXVE4YY-3BFURqnV.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BzurDjWI.js → chunk-FMBD7UC4-VbVgWRBP.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BKDb4ajW.js → chunk-OYMX7WX6-P0Kgp9FK.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B8wWCxA6.js → chunk-QZHKN3VN-C4tZsUim.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-Co_1Eg8t.js → chunk-YZCP3GAM-DGxN4WQV.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-C2Hsd0Kb.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C2Hsd0Kb.js +1 -0
- package/dist/assets/clone-ULoi9D6A.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Dg4b_18w.js → cose-bilkent-S5V4N54A-uWd9Sg8T.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DZ7GGamV.js → dagre-KV5264BT-Bpno7Sui.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-CQXLIXDK.js → diagram-5BDNPKRD-uXKJXOTX.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-CLmTdqBP.js → diagram-G4DWMVQ6-CWAx_MrF.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-cxyC4hjB.js → diagram-MMDJMWI5-C-apgI2M.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C4XJZ9Y1.js → diagram-TYMM5635-oE6H2pqq.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-TBt_wVCl.js → erDiagram-SMLLAGMA-BfgDxyIM.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BWrSN5xe.js → flowDiagram-DWJPFMVM-BLkrIcA5.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CtuTJUg4.js → ganttDiagram-T4ZO3ILL-Cgyf68mR.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-D29sTxA1.js → gitGraphDiagram-UUTBAWPF-dXvSLru9.js} +1 -1
- package/dist/assets/{graph-CloN3583.js → graph-ClhNJ_no.js} +1 -1
- package/dist/assets/{highlighted-body-OFNGDK62-C10Tf0Im.js → highlighted-body-OFNGDK62-CAGQz_wQ.js} +1 -1
- package/dist/assets/{index-DQIDPyEq.js → index-CGOCC7tt.js} +245 -245
- package/dist/assets/index-Cks9xRnD.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-B1rtvZwC.js → infoDiagram-42DDH7IO-CoU2GA9H.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-BeWh70hn.js → ishikawaDiagram-UXIWVN3A-CNYIiSHo.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-S4jNWnUK.js → journeyDiagram-VCZTEJTY-BIOf8Fr8.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CfzGPjgv.js → kanban-definition-6JOO6SKY-DoKVT16A.js} +1 -1
- package/dist/assets/{layout-Brt7T0Xl.js → layout-BP7JvoUj.js} +1 -1
- package/dist/assets/{linear-BUqZJAG0.js → linear-BaHWv3Cv.js} +1 -1
- package/dist/assets/{min-BlHJHGD9.js → min-B323zLjI.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DnqZCBYb.js → mindmap-definition-QFDTVHPH-831I92Ss.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-BWckOPtQ.js → pieDiagram-DEJITSTG-lzn2BmD0.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D8FUl0ef.js → quadrantDiagram-34T5L4WZ-CUKMogxF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BV_AxS8a.js → requirementDiagram-MS252O5E-BhrbxNGm.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BsezkvWP.js → sankeyDiagram-XADWPNL6-eCYinXw3.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-CSHHvCke.js → sequenceDiagram-FGHM5R23-BhykTJJs.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BKwl9kCH.js → stateDiagram-FHFEXIEX-BeJDpoIy.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-C4HmXN_E.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BvGYrKNf.js → timeline-definition-GMOUNBTQ-C-Z1gWQE.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-cD01C6MK.js → vennDiagram-DHZGUBPP-CN_umDEu.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CWSp-_IX.js → wardley-RL74JXVD-BlJ0F_eo.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DIh7DRrk.js → wardleyDiagram-NUSXRM2D-7eU2x8b0.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-BKxR1LZs.js → xychartDiagram-5P7HB3ND-BVz98NOl.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/dist/assets/channel-P0MVlL3z.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-6IwJocki.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-6IwJocki.js +0 -1
- package/dist/assets/clone-DSVAkR4q.js +0 -1
- package/dist/assets/index-aO_3bBjW.css +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqnXAHBi.js +0 -1
package/dist/api/server.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
:
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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",
|
|
790
|
+
"style_guide.md", "parent_canon.md", "fanfic_canon.md",
|
|
598
791
|
];
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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:
|
|
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({
|
|
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
|
|
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
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1495
|
+
const textModels = filterTextChatModels(models);
|
|
1496
|
+
if (textModels.length > 0) {
|
|
1132
1497
|
const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
|
|
1133
|
-
const resolved = await resolveServiceModel(svcName,
|
|
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:
|
|
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
|
|
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
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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(
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1786
|
+
const actionExecutionError = validateAgentActionExecution({
|
|
1787
|
+
instruction,
|
|
1788
|
+
agentBookId,
|
|
1789
|
+
responseText: fallback.content,
|
|
1790
|
+
collectedToolExecs,
|
|
1298
1791
|
});
|
|
1299
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1609
|
-
await
|
|
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) {
|