@musnows/scriverse 1.1.1 → 1.1.3

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/README.en.md CHANGED
@@ -109,6 +109,7 @@ Run `scriverse --help` for all local server, default server, authentication, wor
109
109
  | `SCRIVERSE_AI_RETRY_COUNT` | `3` | Retry count for AI upstream HTTP errors other than `403`, `429`, and `502`; valid integers are clamped to `1`–`20` |
110
110
  | `SCRIVERSE_AI_BACKOFF_RETRY_COUNT` | `10` | Backoff retry count when an AI upstream returns `429` or `502`; valid integers are clamped to `1`–`20` |
111
111
  | `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` | `30` | Maximum idle time while an interactive AI stream waits for its first or next valid event; valid integers are clamped to `10`–`120`, and invalid values fall back to `30` |
112
+ | `SCRIVERSE_AI_RESPONSE_MAX_BYTES` | `0` (unlimited) | Byte limit for AI provider and Desktop local AI responses; a positive safe integer enables the limit, while unset, `0`, and invalid values mean unlimited |
112
113
  | `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` | `6000` | Maximum character length for chapter comments and todos; valid integers are clamped to `2000`–`20000`, values below `2000` are treated as `2000`, and invalid values fall back to `6000` |
113
114
  | `APP_AUTH_USERNAME` | Empty | Optional deployment gateway username; the in-app user system is always enabled |
114
115
  | `APP_AUTH_PASSWORD` | Empty | Optional deployment gateway password, at least 12 characters; must be transported over HTTPS |
@@ -121,6 +122,8 @@ Run `scriverse --help` for all local server, default server, authentication, wor
121
122
 
122
123
  `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` is read when the service starts and only controls how long an interactive AI stream may remain without a new event. Every valid stream event restarts the timer, so generation may continue beyond 60 seconds; this setting does not impose a total-duration limit or change timeout behavior for analysis tasks and other AI requests. Restart the service after changing it.
123
124
 
125
+ `SCRIVERSE_AI_RESPONSE_MAX_BYTES` controls the byte limit for streamed and regular AI provider responses, as well as Desktop local AI responses. The limit is disabled by default; unset, `0`, and invalid values mean unlimited. Set a positive safe integer to enable a byte limit. Restart the service after changing it.
126
+
124
127
  `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` is read when the service starts and applies to API validation, the comment input, and AI write plans. Restart the service after changing it.
125
128
 
126
129
  The AI upstream HTTP retry settings are read when the service starts. `403` is never retried. `429` and `502` use exponential backoff starting at 500 milliseconds and capped at 5 seconds, honoring a numeric `Retry-After` within the same cap; other HTTP errors use a linear delay. Invalid values fall back to their defaults. Restart the service after changing either setting.
package/README.md CHANGED
@@ -117,6 +117,7 @@ CLI 会按服务器保存登录凭据。所有连接服务的数据命令都可
117
117
  | `SCRIVERSE_AI_RETRY_COUNT` | `3` | AI 上游返回除 `403`、`429`、`502` 外的 HTTP 错误时的重试次数;有效整数按 `1`–`20` 钳制 |
118
118
  | `SCRIVERSE_AI_BACKOFF_RETRY_COUNT` | `10` | AI 上游返回 `429` 或 `502` 时的退避重试次数;有效整数按 `1`–`20` 钳制 |
119
119
  | `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` | `30` | 交互式 AI 流等待首个或下一个有效事件的最长空闲秒数;有效整数按 `10`–`120` 钳制,非法值回退为 `30` |
120
+ | `SCRIVERSE_AI_RESPONSE_MAX_BYTES` | `0`(默认不限) | AI 供应商和 Desktop 本地 AI 响应的字节上限;正安全整数启用,未设置、`0` 或非法值表示不限 |
120
121
  | `APP_AI_CHAT_TAB_LIMIT` | `5` | 浏览器中可同时打开的 Agent 对话数;有效整数按 `1`–`20` 钳制,设为 `1` 时关闭多会话切换和工作台 |
121
122
  | `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` | `6000` | 正文评论和待办内容的最大字符数;有效整数按 `2000`–`20000` 钳制,低于 `2000` 按 `2000` 处理,非法值回退为 `6000` |
122
123
  | `APP_AUTH_USERNAME` | 空 | 可选的部署网关账号;应用内用户系统始终启用 |
@@ -138,6 +139,8 @@ CLI 会按服务器保存登录凭据。所有连接服务的数据命令都可
138
139
 
139
140
  `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` 在服务启动时读取,只控制交互式 AI 流连续没有新事件的等待时间。每收到一个有效流事件都会重新计时,持续生成超过 60 秒不会因此中断;该配置不设置总时长上限,也不改变分析任务等其他 AI 请求的超时策略。修改后需重启服务生效。
140
141
 
142
+ `SCRIVERSE_AI_RESPONSE_MAX_BYTES` 控制 AI 供应商流式响应、普通响应和 Desktop 本地 AI 响应的字节上限。默认关闭上限;未设置、设为 `0` 或非法值时不限,设为正安全整数后按该字节数限制。修改后需重启服务生效。
143
+
141
144
  `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` 在服务启动时读取,同时约束 API、界面输入和 AI 写计划中的正文评论与待办。修改后需重启服务生效。
142
145
 
143
146
  AI 上游 HTTP 重试配置在服务启动时读取。`403` 始终不重试;`429` 和 `502` 使用指数退避,等待从 500 毫秒开始并在 5 秒封顶,同时遵循秒数格式的 `Retry-After`(同样最多等待 5 秒);其余 HTTP 错误使用线性等待。非法配置回退到对应默认值,修改后需重启服务生效。
@@ -0,0 +1,12 @@
1
+ export const AI_RESPONSE_MAX_BYTES_ENV = "SCRIVERSE_AI_RESPONSE_MAX_BYTES";
2
+ export function resolveAiResponseMaxBytes(environment = process.env) {
3
+ const raw = environment[AI_RESPONSE_MAX_BYTES_ENV]?.trim() ?? "";
4
+ if (!/^\d+$/u.test(raw))
5
+ return null;
6
+ const configured = Number(raw);
7
+ return Number.isSafeInteger(configured) && configured > 0 ? configured : null;
8
+ }
9
+ export function isAiResponseByteLimitExceeded(receivedBytes, maximumBytes) {
10
+ return maximumBytes !== null && receivedBytes > maximumBytes;
11
+ }
12
+ //# sourceMappingURL=ai-response-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai-response-limit.js","sourceRoot":"","sources":["../src/ai-response-limit.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,yBAAyB,GAAG,iCAAiC,CAAC;AAE3E,MAAM,UAAU,yBAAyB,CAAC,cAAiC,OAAO,CAAC,GAAG;IACpF,MAAM,GAAG,GAAG,WAAW,CAAC,yBAAyB,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACjE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC/B,OAAO,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC;AAChF,CAAC;AAED,MAAM,UAAU,6BAA6B,CAAC,aAAqB,EAAE,YAA2B;IAC9F,OAAO,YAAY,KAAK,IAAI,IAAI,aAAa,GAAG,YAAY,CAAC;AAC/D,CAAC"}
package/dist/ai.js CHANGED
@@ -8,6 +8,7 @@ import { AiConnectivityTestGate, hashAiConnectivityConfiguration } from "./ai-co
8
8
  import { aiHttpRetryCount, aiHttpRetryDelayMs, normalizeAiRetryPolicy } from "./ai-retry.js";
9
9
  import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
10
10
  import { DEFAULT_AI_CHAT_IMAGE_MAX_BYTES, formatUploadLimit } from "./upload-limits.js";
11
+ import { isAiResponseByteLimitExceeded, resolveAiResponseMaxBytes } from "./ai-response-limit.js";
11
12
  import { characterExtractionHash, characterExtractionSelectionFingerprint, editableCharacterExtractionCandidate, normalizeCharacterExtractionCandidate, parseStoredCharacterExtractionCandidates } from "./character-extraction.js";
12
13
  import { AI_WRITE_TOOL_IDS, aiWritePlanOperationToolSchemas, askAiUserQuestionInputSchema } from "./ai-write-plans.js";
13
14
  import { DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH } from "./chapter-annotation-note.js";
@@ -169,15 +170,18 @@ function completionSkillsTokens(messages) {
169
170
  // A small but non-transparent 128x128 PNG. The model test must exercise an actual image_url
170
171
  // payload, while keeping the request cheap and avoiding any user data in the probe.
171
172
  const MULTIMODAL_TEST_IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACfklEQVR4nO2cwY3EQBACJ8LOglRJyw4DJOpR/xOUuF17Zp91H9xsBi/9B8AhABIcC4AEx78AJDg+AyDB8SEQCY5vAUhwfA1EguM5ABIcD4KQ4HgSiATHo2AkON4FIMHxMggJjreBSHC8DkaC4zwAEhwHQpDgOBGEBMeRMCQ4zgQiwXEoFAmOU8FIcBwLR4LjXgASHBdDkOC4GYQEx9UwJDjuBiLBcTkUCY7bwUhwXA8319P5fQCPS8APRChfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeBQWQPkSEKAgCI/CAihfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeJQfFY4JQ620WGEAAAAASUVORK5CYII=";
172
- /** 出站 AI 响应体上限,防止恶意或故障供应商推送超大响应拖垮进程。 */
173
- export const AI_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
174
- export async function readResponseTextLimited(response, maximumBytes = AI_RESPONSE_MAX_BYTES) {
173
+ export async function readResponseTextLimited(response, maximumBytes = resolveAiResponseMaxBytes()) {
175
174
  const declared = response.headers.get("content-length");
176
- if (declared && /^\d+$/u.test(declared) && Number(declared) > maximumBytes) {
175
+ if (maximumBytes !== null && declared && /^\d+$/u.test(declared) && Number(declared) > maximumBytes) {
177
176
  throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${maximumBytes} 字节上限`);
178
177
  }
179
- if (!response.body)
180
- return response.text();
178
+ if (!response.body) {
179
+ const text = await response.text();
180
+ if (isAiResponseByteLimitExceeded(Buffer.byteLength(text, "utf8"), maximumBytes)) {
181
+ throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${maximumBytes} 字节上限`);
182
+ }
183
+ return text;
184
+ }
181
185
  const reader = response.body.getReader();
182
186
  const chunks = [];
183
187
  let total = 0;
@@ -188,7 +192,7 @@ export async function readResponseTextLimited(response, maximumBytes = AI_RESPON
188
192
  if (!value?.byteLength)
189
193
  continue;
190
194
  total += value.byteLength;
191
- if (total > maximumBytes) {
195
+ if (isAiResponseByteLimitExceeded(total, maximumBytes)) {
192
196
  await reader.cancel().catch(() => undefined);
193
197
  throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${maximumBytes} 字节上限`);
194
198
  }
@@ -236,7 +240,6 @@ export function autoRunFailureDisposition(error, attemptCount) {
236
240
  }
237
241
  const DESKTOP_LOCAL_AI_RUN_LIMIT = 20;
238
242
  const DESKTOP_LOCAL_AI_RUN_RETENTION_MS = 10 * 60_000;
239
- const DESKTOP_LOCAL_AI_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
240
243
  const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
241
244
  const DEFAULT_MAX_TOKENS = 32_000;
242
245
  const MAX_MODEL_OUTPUT_TOKENS = 2_000_000;
@@ -2438,6 +2441,7 @@ export class AiManager {
2438
2441
  retryPolicy;
2439
2442
  retrySleep;
2440
2443
  liteLlmPriceCache;
2444
+ conversationTitleGenerations = new Map();
2441
2445
  taskControllers = new Map();
2442
2446
  autoRunStarting = new Map();
2443
2447
  autoRunTimers = new Map();
@@ -5141,13 +5145,13 @@ export class AiManager {
5141
5145
  const titleSettings = this.store.getWorkAiSettings(input.workId);
5142
5146
  const titleModelId = typeof titleSettings.titleGenerationModelId === "string" ? titleSettings.titleGenerationModelId : "";
5143
5147
  const defaultTitle = firstUserContent ? defaultAiConversationTitle(firstUserContent) : "";
5144
- const isCompletingSecondAssistantTurn = conversationBefore?.messages.at(-1)?.role === "user"
5145
- && userMessages.length === 2
5146
- && assistantMessages.length === 1;
5148
+ const isCompletingFirstAssistantTurn = conversationBefore?.messages.at(-1)?.role === "user"
5149
+ && userMessages.length === 1
5150
+ && assistantMessages.length === 0;
5147
5151
  const shouldGenerateTitle = Boolean(input.conversationId
5148
5152
  && firstUserContent
5149
5153
  && titleModelId
5150
- && isCompletingSecondAssistantTurn
5154
+ && isCompletingFirstAssistantTurn
5151
5155
  && (conversationBefore?.title === "新对话" || conversationBefore?.title === defaultTitle));
5152
5156
  const processStartedAt = process.hrtime.bigint();
5153
5157
  let persistedConversationMessage = null;
@@ -5258,14 +5262,26 @@ export class AiManager {
5258
5262
  });
5259
5263
  }
5260
5264
  }
5265
+ let conversationTitleGenerationStarted = false;
5261
5266
  if (shouldGenerateTitle && conversationMessage && input.conversationId) {
5262
- void this.generateConversationTitle(input.workId, input.conversationId, titleModelId, [
5267
+ conversationTitleGenerationStarted = true;
5268
+ const generation = this.generateConversationTitle(input.workId, input.conversationId, titleModelId, [
5263
5269
  ...(conversationBefore?.messages ?? []),
5264
5270
  { role: "assistant", content: generated.content }
5265
5271
  ], defaultTitle).catch((error) => {
5266
5272
  logger.warn("ai.conversation_title.failed", { workId: input.workId, conversationId: input.conversationId, error: aiErrorForLog(error) });
5273
+ return null;
5274
+ });
5275
+ this.conversationTitleGenerations.set(input.conversationId, generation);
5276
+ void generation.then(() => {
5277
+ if (this.conversationTitleGenerations.get(input.conversationId) === generation) {
5278
+ this.conversationTitleGenerations.delete(input.conversationId);
5279
+ }
5267
5280
  });
5268
5281
  }
5282
+ const updatedConversation = input.conversationId
5283
+ ? this.store.getAiConversationSummary(input.conversationId)
5284
+ : null;
5269
5285
  return {
5270
5286
  callId: generated.callId,
5271
5287
  content: generated.content,
@@ -5277,10 +5293,15 @@ export class AiManager {
5277
5293
  toolCalls: generated.toolCalls,
5278
5294
  processSteps: generated.processSteps,
5279
5295
  contextUsage: generated.contextUsage,
5296
+ conversationTitle: updatedConversation?.title ?? "新对话",
5297
+ conversationTitleGenerationStarted,
5280
5298
  roleplayMemoriesCommitted: committedRoleplayMemories,
5281
5299
  ...(conversationMessage ? { conversationMessage } : {})
5282
5300
  };
5283
5301
  }
5302
+ async waitForConversationTitle(conversationId) {
5303
+ await this.conversationTitleGenerations.get(conversationId);
5304
+ }
5284
5305
  async resumeUserQuestion(input, stream = {}) {
5285
5306
  const answeredItems = (input.answers ?? []).map((answer) => ({
5286
5307
  question: String(answer.question ?? ""),
@@ -5943,8 +5964,10 @@ export class AiManager {
5943
5964
  if (pending.request.requestId !== input.requestId) {
5944
5965
  throw new AppError(409, "DESKTOP_LOCAL_AI_REQUEST_MISMATCH", "Desktop 本地 AI 响应与当前请求不匹配");
5945
5966
  }
5946
- if (Buffer.byteLength(input.body, "utf8") > DESKTOP_LOCAL_AI_RESPONSE_MAX_BYTES) {
5947
- throw new AppError(413, "DESKTOP_LOCAL_AI_RESPONSE_TOO_LARGE", "Desktop 本地 AI 响应过大");
5967
+ const maximumResponseBytes = resolveAiResponseMaxBytes();
5968
+ const responseBytes = Buffer.byteLength(input.body, "utf8");
5969
+ if (isAiResponseByteLimitExceeded(responseBytes, maximumResponseBytes)) {
5970
+ throw new AppError(413, "DESKTOP_LOCAL_AI_RESPONSE_TOO_LARGE", `Desktop 本地 AI 响应超过 ${maximumResponseBytes} 字节上限`);
5948
5971
  }
5949
5972
  run.pending = null;
5950
5973
  run.status = "running";
@@ -9955,6 +9978,7 @@ export class AiManager {
9955
9978
  onThinkingDelta(finalReasoning);
9956
9979
  }
9957
9980
  };
9981
+ const maximumResponseBytes = resolveAiResponseMaxBytes();
9958
9982
  let receivedBytes = 0;
9959
9983
  let readerEnded = false;
9960
9984
  try {
@@ -9970,9 +9994,9 @@ export class AiManager {
9970
9994
  }
9971
9995
  if (chunk.value?.byteLength) {
9972
9996
  receivedBytes += chunk.value.byteLength;
9973
- if (receivedBytes > AI_RESPONSE_MAX_BYTES) {
9997
+ if (isAiResponseByteLimitExceeded(receivedBytes, maximumResponseBytes)) {
9974
9998
  await reader.cancel().catch(() => undefined);
9975
- throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${AI_RESPONSE_MAX_BYTES} 字节上限`);
9999
+ throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${maximumResponseBytes} 字节上限`);
9976
10000
  }
9977
10001
  }
9978
10002
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });