@musnows/scriverse 0.7.3 → 0.7.4
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 +3 -0
- package/README.md +3 -0
- package/dist/ai-connectivity-test.js +109 -0
- package/dist/ai-connectivity-test.js.map +1 -0
- package/dist/ai-conversation-export.js +70 -0
- package/dist/ai-conversation-export.js.map +1 -0
- package/dist/ai-stream-timeout.js +18 -0
- package/dist/ai-stream-timeout.js.map +1 -0
- package/dist/ai.js +681 -107
- package/dist/ai.js.map +1 -1
- package/dist/app.js +326 -53
- package/dist/app.js.map +1 -1
- package/dist/character-extraction.js +133 -0
- package/dist/character-extraction.js.map +1 -0
- package/dist/cli-core.js +7 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +175 -2
- package/dist/database.js.map +1 -1
- package/dist/epub-export.js +319 -0
- package/dist/epub-export.js.map +1 -0
- package/dist/hybrid-search.js +8 -0
- package/dist/hybrid-search.js.map +1 -1
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +2567 -265
- package/dist/public/chapter-version-diff.d.ts +20 -0
- package/dist/public/chapter-version-diff.js +116 -0
- package/dist/public/foreshadow-reminder.d.ts +32 -0
- package/dist/public/foreshadow-reminder.js +73 -0
- package/dist/public/global-replace-refresh.js +60 -0
- package/dist/public/index.html +120 -12
- package/dist/public/outline-board.d.ts +61 -0
- package/dist/public/outline-board.js +137 -0
- package/dist/public/page-route.d.ts +1 -0
- package/dist/public/page-route.js +8 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/styles.css +485 -4
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/security.js +5 -2
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +825 -88
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +45 -9
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/app.js
CHANGED
|
@@ -10,18 +10,22 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { pipeline } from "node:stream/promises";
|
|
11
11
|
import { z, ZodError } from "zod";
|
|
12
12
|
import { AI_PROVIDER_PROTOCOLS } from "./ai-protocol.js";
|
|
13
|
+
import { aiConversationExportContentDisposition, exportAiConversationMarkdown } from "./ai-conversation-export.js";
|
|
14
|
+
import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS } from "./ai-stream-timeout.js";
|
|
13
15
|
import { AttachmentStorage } from "./attachment-storage.js";
|
|
14
16
|
import { AiManager } from "./ai.js";
|
|
15
17
|
import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
|
|
18
|
+
import { CHARACTER_EXTRACTION_MAX_ALIASES, CHARACTER_EXTRACTION_MAX_CANDIDATES, CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH, CHARACTER_EXTRACTION_MAX_NAME_LENGTH, CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH } from "./character-extraction.js";
|
|
16
19
|
import { CredentialVault } from "./credential-vault.js";
|
|
17
20
|
import { Database } from "./database.js";
|
|
18
21
|
import { assertSafeDocxArchive } from "./docx-security.js";
|
|
22
|
+
import { EPUB_MIME_TYPE, epubContentDisposition } from "./epub-export.js";
|
|
19
23
|
import { CREATABLE_ANALYSIS_TASK_TYPES, DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
|
|
20
24
|
import { AppError } from "./errors.js";
|
|
21
25
|
import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
|
|
22
|
-
import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
|
|
26
|
+
import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH } from "./hybrid-search.js";
|
|
23
27
|
import { applyImportFileHints, parseNovelText } from "./parser.js";
|
|
24
|
-
import { aiConversationTaskTypes, attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
|
|
28
|
+
import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes } from "./store.js";
|
|
25
29
|
import { paginated, parsePagination } from "./pagination.js";
|
|
26
30
|
import { normalizeUploadFileName } from "./utils.js";
|
|
27
31
|
import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
|
|
@@ -41,6 +45,8 @@ import { PresenceStore } from "./presence-store.js";
|
|
|
41
45
|
import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
|
|
42
46
|
const nonEmpty = z.string().trim().min(1);
|
|
43
47
|
const identifier = z.string().trim().min(1).max(200);
|
|
48
|
+
const idempotencyKeySchema = z.string().trim().min(16).max(128)
|
|
49
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u, "幂等键只能包含英文字母、数字、点、下划线、冒号和短横线");
|
|
44
50
|
const optionalStrings = z.array(z.string()).optional();
|
|
45
51
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
46
52
|
const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
|
|
@@ -49,6 +55,18 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
|
|
|
49
55
|
const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
|
|
50
56
|
const maximumImportedTextLength = 20_000_000;
|
|
51
57
|
const maximumKnowledgeSectionsLength = 4_000_000;
|
|
58
|
+
function stableJson(value) {
|
|
59
|
+
if (Array.isArray(value))
|
|
60
|
+
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
return `{${Object.entries(value)
|
|
63
|
+
.filter(([, item]) => item !== undefined)
|
|
64
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
65
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
66
|
+
.join(",")}}`;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(value) ?? "null";
|
|
69
|
+
}
|
|
52
70
|
function assertImageUploadSize(byteLength, maximumBytes, message) {
|
|
53
71
|
if (byteLength <= maximumBytes)
|
|
54
72
|
return;
|
|
@@ -607,6 +625,34 @@ const analysisTaskSchema = z.union([
|
|
|
607
625
|
}
|
|
608
626
|
})
|
|
609
627
|
]);
|
|
628
|
+
const characterExtractionSelectionSchema = z.object({
|
|
629
|
+
candidateId: z.string().trim().min(1).max(100).regex(/^[A-Za-z0-9_-]+$/u),
|
|
630
|
+
action: z.enum(["create", "merge", "skip"]),
|
|
631
|
+
targetCharacterId: identifier.optional(),
|
|
632
|
+
name: z.string().trim().min(1).max(CHARACTER_EXTRACTION_MAX_NAME_LENGTH).optional(),
|
|
633
|
+
aliases: z.array(z.string().trim().min(1).max(CHARACTER_EXTRACTION_MAX_NAME_LENGTH))
|
|
634
|
+
.max(CHARACTER_EXTRACTION_MAX_ALIASES).optional(),
|
|
635
|
+
species: z.string().trim().max(CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH).optional(),
|
|
636
|
+
attributes: z.object({
|
|
637
|
+
identity: z.string().trim().max(CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH).optional()
|
|
638
|
+
}).strict().optional()
|
|
639
|
+
}).strict().superRefine((selection, context) => {
|
|
640
|
+
if (selection.action === "merge" && !selection.targetCharacterId) {
|
|
641
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ["targetCharacterId"], message: "合并角色候选必须选择目标角色" });
|
|
642
|
+
}
|
|
643
|
+
if (selection.action !== "merge" && selection.targetCharacterId !== undefined) {
|
|
644
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ["targetCharacterId"], message: "只有合并操作可以指定目标角色" });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
const characterExtractionApplySchema = z.object({
|
|
648
|
+
previewToken: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
649
|
+
selections: z.array(characterExtractionSelectionSchema).min(1).max(CHARACTER_EXTRACTION_MAX_CANDIDATES)
|
|
650
|
+
}).strict().superRefine((input, context) => {
|
|
651
|
+
const candidateIds = input.selections.map((selection) => selection.candidateId);
|
|
652
|
+
if (new Set(candidateIds).size !== candidateIds.length) {
|
|
653
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ["selections"], message: "角色候选不能重复" });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
610
656
|
export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
|
|
611
657
|
function data(response, value, status = 200) {
|
|
612
658
|
response.status(status).json({ data: value });
|
|
@@ -614,6 +660,18 @@ function data(response, value, status = 200) {
|
|
|
614
660
|
function noContent(response) {
|
|
615
661
|
response.status(204).end();
|
|
616
662
|
}
|
|
663
|
+
async function sendEpub(response, archive, title, fallbackStem) {
|
|
664
|
+
response.type(EPUB_MIME_TYPE);
|
|
665
|
+
response.setHeader("Content-Disposition", epubContentDisposition(title, fallbackStem));
|
|
666
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
667
|
+
await pipeline(archive.generateNodeStream({
|
|
668
|
+
type: "nodebuffer",
|
|
669
|
+
// 逐条目压缩后再写入,确保首个 mimetype 本地头包含确定长度且没有额外字段。
|
|
670
|
+
streamFiles: false,
|
|
671
|
+
compression: "DEFLATE",
|
|
672
|
+
compressionOptions: { level: 6 }
|
|
673
|
+
}), response);
|
|
674
|
+
}
|
|
617
675
|
function parse(schema, value) {
|
|
618
676
|
return schema.parse(value);
|
|
619
677
|
}
|
|
@@ -876,7 +934,9 @@ export function publicAiStreamError(error) {
|
|
|
876
934
|
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
877
935
|
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
878
936
|
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
879
|
-
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
|
|
937
|
+
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {}),
|
|
938
|
+
...(typeof details?.phase === "string" ? { phase: details.phase } : {}),
|
|
939
|
+
...(typeof details?.idleTimeoutSeconds === "number" ? { idleTimeoutSeconds: details.idleTimeoutSeconds } : {})
|
|
880
940
|
};
|
|
881
941
|
}
|
|
882
942
|
return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
|
|
@@ -1007,7 +1067,7 @@ export function createRuntime(options) {
|
|
|
1007
1067
|
read: requiredModules,
|
|
1008
1068
|
write: ["ai-analysis"]
|
|
1009
1069
|
}, false, actor?.allowAdminAccess ?? false);
|
|
1010
|
-
}, attachmentStorage);
|
|
1070
|
+
}, attachmentStorage, { interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS });
|
|
1011
1071
|
const app = express();
|
|
1012
1072
|
enforceCaseInsensitiveRouting(app);
|
|
1013
1073
|
const upload = multer({
|
|
@@ -1226,6 +1286,23 @@ export function createRuntime(options) {
|
|
|
1226
1286
|
const pagination = parsePagination(request.query);
|
|
1227
1287
|
data(response, pagination ? store.listWorksPage(pagination) : store.listWorks());
|
|
1228
1288
|
});
|
|
1289
|
+
app.get("/api/recycle-bin/works", (_request, response) => {
|
|
1290
|
+
data(response, { retentionDays: RECYCLE_BIN_RETENTION_DAYS, works: store.listDeletedWorks() });
|
|
1291
|
+
});
|
|
1292
|
+
app.post("/api/recycle-bin/works/:workId/restore", (request, response) => {
|
|
1293
|
+
if (request.authUser)
|
|
1294
|
+
auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
|
|
1295
|
+
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1296
|
+
data(response, store.restoreWork(request.params.workId, input.expectedVersionNo));
|
|
1297
|
+
});
|
|
1298
|
+
app.delete("/api/recycle-bin/works/:workId/permanent", async (request, response) => {
|
|
1299
|
+
if (request.authUser)
|
|
1300
|
+
auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
|
|
1301
|
+
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1302
|
+
store.permanentlyDeleteWork(request.params.workId, input.expectedVersionNo);
|
|
1303
|
+
await cleanupAttachments();
|
|
1304
|
+
noContent(response);
|
|
1305
|
+
});
|
|
1229
1306
|
app.post("/api/works", (request, response) => data(response, store.createWork(parse(workSchema, request.body)), 201));
|
|
1230
1307
|
app.post("/api/works/import", upload.single("file"), async (request, response) => {
|
|
1231
1308
|
if (!request.file)
|
|
@@ -1310,7 +1387,6 @@ export function createRuntime(options) {
|
|
|
1310
1387
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
1311
1388
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1312
1389
|
store.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
1313
|
-
await cleanupAttachments();
|
|
1314
1390
|
noContent(response);
|
|
1315
1391
|
});
|
|
1316
1392
|
app.get("/api/works/:workId/cover", (request, response) => {
|
|
@@ -1390,11 +1466,31 @@ export function createRuntime(options) {
|
|
|
1390
1466
|
? store.listVolumeChaptersPage(request.params.volumeId, pagination)
|
|
1391
1467
|
: store.listVolumeChapters(request.params.volumeId));
|
|
1392
1468
|
});
|
|
1469
|
+
app.head("/api/volumes/:volumeId/export", (request, response) => {
|
|
1470
|
+
parse(z.enum(["epub"]), request.query.format ?? "epub");
|
|
1471
|
+
store.getVolume(request.params.volumeId);
|
|
1472
|
+
noContent(response);
|
|
1473
|
+
});
|
|
1474
|
+
app.get("/api/volumes/:volumeId/export", async (request, response) => {
|
|
1475
|
+
parse(z.enum(["epub"]), request.query.format ?? "epub");
|
|
1476
|
+
const exported = await store.exportVolumeEpub(request.params.volumeId);
|
|
1477
|
+
await sendEpub(response, exported.archive, exported.title, `volume-${request.params.volumeId}`);
|
|
1478
|
+
});
|
|
1393
1479
|
app.delete("/api/volumes/:volumeId", (request, response) => {
|
|
1394
1480
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1395
1481
|
store.deleteVolume(request.params.volumeId, input.expectedVersionNo);
|
|
1396
1482
|
noContent(response);
|
|
1397
1483
|
});
|
|
1484
|
+
app.post("/api/volumes/:volumeId/restore", (request, response) => {
|
|
1485
|
+
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1486
|
+
data(response, store.restoreVolume(request.params.volumeId, input.expectedVersionNo));
|
|
1487
|
+
});
|
|
1488
|
+
app.delete("/api/volumes/:volumeId/permanent", async (request, response) => {
|
|
1489
|
+
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1490
|
+
store.permanentlyDeleteVolume(request.params.volumeId, input.expectedVersionNo);
|
|
1491
|
+
await cleanupAttachments();
|
|
1492
|
+
noContent(response);
|
|
1493
|
+
});
|
|
1398
1494
|
app.post("/api/works/:workId/chapters", (request, response) => {
|
|
1399
1495
|
const input = parse(z.object({ volumeId: identifier, title: nonEmpty.max(300), content: z.string().max(2_000_000).optional(), chapterType: chapterTypeSchema.optional() }), request.body);
|
|
1400
1496
|
data(response, store.createChapter(request.params.workId, input), 201);
|
|
@@ -1405,6 +1501,9 @@ export function createRuntime(options) {
|
|
|
1405
1501
|
? store.listDeletedChaptersPage(request.params.workId, pagination)
|
|
1406
1502
|
: store.listDeletedChapters(request.params.workId));
|
|
1407
1503
|
});
|
|
1504
|
+
app.get("/api/works/:workId/recycle-bin", (request, response) => {
|
|
1505
|
+
data(response, store.getRecycleBin(request.params.workId));
|
|
1506
|
+
});
|
|
1408
1507
|
app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
|
|
1409
1508
|
app.patch("/api/chapters/:chapterId", (request, response) => {
|
|
1410
1509
|
const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
@@ -1484,6 +1583,9 @@ export function createRuntime(options) {
|
|
|
1484
1583
|
const pagination = parsePagination(request.query);
|
|
1485
1584
|
data(response, pagination ? store.listChapterOutlinesPage(request.params.workId, pagination) : store.listChapterOutlines(request.params.workId));
|
|
1486
1585
|
});
|
|
1586
|
+
app.get("/api/works/:workId/outline-board", (request, response) => {
|
|
1587
|
+
data(response, store.getChapterOutlineBoard(request.params.workId));
|
|
1588
|
+
});
|
|
1487
1589
|
app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
|
|
1488
1590
|
app.put("/api/chapters/:chapterId/outline", (request, response) => {
|
|
1489
1591
|
const { changeNote, expectedVersionNo, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
@@ -1495,6 +1597,13 @@ export function createRuntime(options) {
|
|
|
1495
1597
|
store.deleteChapterOutline(request.params.chapterId, input.expectedVersionNo);
|
|
1496
1598
|
noContent(response);
|
|
1497
1599
|
});
|
|
1600
|
+
app.get("/api/works/:workId/chapters/:chapterId/foreshadow-reminders", (request, response) => {
|
|
1601
|
+
data(response, store.listChapterForeshadowReminders(request.params.workId, request.params.chapterId));
|
|
1602
|
+
});
|
|
1603
|
+
app.post("/api/works/:workId/chapters/:chapterId/foreshadow-reminders/:foreshadowId/resolve", (request, response) => {
|
|
1604
|
+
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1605
|
+
data(response, store.resolveChapterForeshadowReminder(request.params.workId, request.params.chapterId, request.params.foreshadowId, input.expectedVersionNo));
|
|
1606
|
+
});
|
|
1498
1607
|
app.get("/api/works/:workId/foreshadows", (request, response) => {
|
|
1499
1608
|
const query = parse(z.object({
|
|
1500
1609
|
status: z.enum(["all", "unresolved", "resolved"]).default("all"),
|
|
@@ -2001,6 +2110,9 @@ export function createRuntime(options) {
|
|
|
2001
2110
|
data(response, ai.resumeAutoRun(request.params.workId));
|
|
2002
2111
|
});
|
|
2003
2112
|
app.get("/api/tasks/:taskId/detail", (request, response) => data(response, redactTaskCharacterNames(store.getTaskDetail(request.params.taskId), requestPermissions(request))));
|
|
2113
|
+
app.get("/api/tasks/:taskId/character-extraction/preview", (request, response) => {
|
|
2114
|
+
data(response, ai.getCharacterExtractionPreview(request.params.taskId));
|
|
2115
|
+
});
|
|
2004
2116
|
app.get("/api/tasks/:taskId/result", (request, response) => {
|
|
2005
2117
|
const task = store.getTaskResultPayload(request.params.taskId);
|
|
2006
2118
|
const permissions = requestPermissions(request);
|
|
@@ -2042,6 +2154,14 @@ export function createRuntime(options) {
|
|
|
2042
2154
|
data(response, redactTaskCharacterNames(ai.rerunTask(request.params.taskId, input.modelId), requestPermissions(request)), 201);
|
|
2043
2155
|
});
|
|
2044
2156
|
app.post("/api/tasks/:taskId/cancel", (request, response) => data(response, redactTaskCharacterNames(ai.cancelTask(request.params.taskId), requestPermissions(request))));
|
|
2157
|
+
app.post("/api/tasks/:taskId/character-extraction/apply", (request, response) => {
|
|
2158
|
+
const input = parse(characterExtractionApplySchema, request.body);
|
|
2159
|
+
const applied = ai.applyCharacterExtractionPreview(request.params.taskId, input.previewToken, input.selections);
|
|
2160
|
+
const characterIds = Array.isArray(applied.characterIds) ? applied.characterIds.filter((value) => typeof value === "string") : [];
|
|
2161
|
+
for (const characterId of characterIds)
|
|
2162
|
+
publishEntityChange(String(store.getTask(request.params.taskId).workId), "character", characterId);
|
|
2163
|
+
data(response, applied);
|
|
2164
|
+
});
|
|
2045
2165
|
app.post("/api/tasks/:taskId/relationship-changes/apply", (request, response) => {
|
|
2046
2166
|
parse(z.object({}).strict(), request.body ?? {});
|
|
2047
2167
|
const applied = ai.applyRelationshipChangePreview(request.params.taskId);
|
|
@@ -2179,10 +2299,26 @@ export function createRuntime(options) {
|
|
|
2179
2299
|
const permissions = requestPermissions(request, String(conversation.workId));
|
|
2180
2300
|
data(response, redactAiConversation(conversation, permissions));
|
|
2181
2301
|
});
|
|
2302
|
+
app.get("/api/ai-conversations/:conversationId/export", (request, response) => {
|
|
2303
|
+
const conversation = store.getAiConversation(request.params.conversationId);
|
|
2304
|
+
const permissions = requestPermissions(request, String(conversation.workId));
|
|
2305
|
+
const readableConversation = redactAiConversation(conversation, permissions);
|
|
2306
|
+
response.type("text/markdown; charset=utf-8");
|
|
2307
|
+
response.setHeader("Content-Disposition", aiConversationExportContentDisposition(readableConversation));
|
|
2308
|
+
response.send(exportAiConversationMarkdown(readableConversation));
|
|
2309
|
+
});
|
|
2182
2310
|
app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
|
|
2183
|
-
const input = parse(z.object({
|
|
2184
|
-
|
|
2185
|
-
|
|
2311
|
+
const input = parse(z.object({
|
|
2312
|
+
messageId: identifier,
|
|
2313
|
+
title: z.string().max(200).optional(),
|
|
2314
|
+
requestId: identifier.optional()
|
|
2315
|
+
}).strict(), request.body);
|
|
2316
|
+
const sourceConversation = store.getAiConversationSummary(request.params.conversationId);
|
|
2317
|
+
const permissions = requestPermissions(request, String(sourceConversation.workId));
|
|
2318
|
+
if (sourceConversation.roleplayCharacter && !canReadWorkModule(permissions, "characters")) {
|
|
2319
|
+
throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取“角色”模块的权限");
|
|
2320
|
+
}
|
|
2321
|
+
const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title, input.requestId);
|
|
2186
2322
|
data(response, redactAiConversation(forked, permissions), 201);
|
|
2187
2323
|
});
|
|
2188
2324
|
app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
|
|
@@ -2214,6 +2350,9 @@ export function createRuntime(options) {
|
|
|
2214
2350
|
outputTokens: z.number().int().min(0).max(10_000_000).optional(),
|
|
2215
2351
|
cacheHitPercent: z.number().min(0).max(100).optional(),
|
|
2216
2352
|
processDurationMs: z.number().int().min(0).max(86_400_000).optional(),
|
|
2353
|
+
interrupted: z.boolean().optional(),
|
|
2354
|
+
interruptionCode: z.string().max(100).optional(),
|
|
2355
|
+
interruptionMessage: z.string().max(500).optional(),
|
|
2217
2356
|
toolCalls: z.array(aiToolCallResultSchema).max(12).optional(),
|
|
2218
2357
|
processSteps: z.array(aiProcessStepSchema).max(50).optional()
|
|
2219
2358
|
}).optional()
|
|
@@ -2274,13 +2413,19 @@ export function createRuntime(options) {
|
|
|
2274
2413
|
ai.deleteProvider(request.params.providerId);
|
|
2275
2414
|
noContent(response);
|
|
2276
2415
|
});
|
|
2277
|
-
app.post("/api/providers/:providerId/test", async (request, response) =>
|
|
2416
|
+
app.post("/api/providers/:providerId/test", async (request, response) => {
|
|
2417
|
+
parse(z.object({}).strict(), request.body ?? {});
|
|
2418
|
+
data(response, await ai.testProvider(request.params.providerId));
|
|
2419
|
+
});
|
|
2278
2420
|
app.get("/api/providers/:providerId/models", (request, response) => {
|
|
2279
2421
|
const pagination = parsePagination(request.query);
|
|
2280
2422
|
data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
|
|
2281
2423
|
});
|
|
2282
2424
|
app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
|
|
2283
|
-
app.post("/api/models/:modelId/test", async (request, response) =>
|
|
2425
|
+
app.post("/api/models/:modelId/test", async (request, response) => {
|
|
2426
|
+
parse(z.object({}).strict(), request.body ?? {});
|
|
2427
|
+
data(response, await ai.testModel(request.params.modelId));
|
|
2428
|
+
});
|
|
2284
2429
|
app.get("/api/models/:modelId", (request, response) => data(response, ai.getModel(request.params.modelId)));
|
|
2285
2430
|
app.patch("/api/models/:modelId", (request, response) => data(response, ai.updateModel(request.params.modelId, parse(modelSchema.partial(), request.body))));
|
|
2286
2431
|
app.delete("/api/models/:modelId", (request, response) => {
|
|
@@ -2341,54 +2486,90 @@ export function createRuntime(options) {
|
|
|
2341
2486
|
conversationId: identifier.optional(),
|
|
2342
2487
|
currentMessageId: identifier.optional()
|
|
2343
2488
|
}), request.body);
|
|
2489
|
+
const providedIdempotencyKey = request.get("Idempotency-Key");
|
|
2490
|
+
const idempotencyKey = providedIdempotencyKey
|
|
2491
|
+
? parse(idempotencyKeySchema, providedIdempotencyKey)
|
|
2492
|
+
: randomUUID();
|
|
2493
|
+
const actorScope = request.authUser ? `user:${request.authUser.userId}` : "auth-disabled";
|
|
2494
|
+
const requestHash = store.hashContent(stableJson({ workId: request.params.workId, ...input }));
|
|
2344
2495
|
const citations = input.citations ?? [];
|
|
2345
2496
|
for (const citation of citations) {
|
|
2346
2497
|
if (store.getChapter(citation.chapterId).workId !== request.params.workId)
|
|
2347
2498
|
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
2348
2499
|
}
|
|
2349
2500
|
const resolvedInstruction = instructionWithCitations(input.instruction, citations);
|
|
2501
|
+
const existingRequest = store.findAiConversationStreamRequest(actorScope, request.params.workId, idempotencyKey);
|
|
2502
|
+
if (existingRequest && input.conversationId && existingRequest.conversationId !== input.conversationId) {
|
|
2503
|
+
throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该请求标识已用于另一项 AI 对话请求");
|
|
2504
|
+
}
|
|
2505
|
+
const conversation = existingRequest
|
|
2506
|
+
? store.getAiConversationSummary(existingRequest.conversationId)
|
|
2507
|
+
: input.conversationId
|
|
2508
|
+
? store.getAiConversationSummary(input.conversationId)
|
|
2509
|
+
: store.createAiConversation(request.params.workId);
|
|
2510
|
+
if (String(conversation.workId) !== request.params.workId) {
|
|
2511
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
2512
|
+
}
|
|
2513
|
+
const conversationId = String(conversation.id);
|
|
2514
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
2350
2515
|
const controller = new AbortController();
|
|
2351
2516
|
response.on("close", () => {
|
|
2352
2517
|
if (!response.writableEnded)
|
|
2353
2518
|
controller.abort(new Error("浏览器已中断流式请求"));
|
|
2354
2519
|
});
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2520
|
+
let streamRequestId = null;
|
|
2521
|
+
let streamRequestFinished = false;
|
|
2522
|
+
let lastStreamLeaseTouchAt = Date.now();
|
|
2523
|
+
const startStream = () => {
|
|
2524
|
+
if (response.headersSent)
|
|
2525
|
+
return;
|
|
2526
|
+
response.status(200);
|
|
2527
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
2528
|
+
response.setHeader("Cache-Control", "no-cache, no-transform");
|
|
2529
|
+
response.setHeader("Connection", "keep-alive");
|
|
2530
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
2531
|
+
response.flushHeaders();
|
|
2532
|
+
};
|
|
2361
2533
|
const sendEvent = (event, payload) => {
|
|
2534
|
+
if (streamRequestId && Date.now() - lastStreamLeaseTouchAt >= 30_000) {
|
|
2535
|
+
store.touchAiConversationStreamRequest(streamRequestId);
|
|
2536
|
+
lastStreamLeaseTouchAt = Date.now();
|
|
2537
|
+
}
|
|
2362
2538
|
if (!response.writableEnded && !response.destroyed)
|
|
2363
2539
|
response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
2364
2540
|
};
|
|
2365
|
-
sendEvent("ready", { streaming: true });
|
|
2366
2541
|
try {
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2542
|
+
if (!existingRequest) {
|
|
2543
|
+
store.assertAiConversationStreamAvailable(conversationId);
|
|
2544
|
+
const inspection = ai.inspectConversationContext({
|
|
2545
|
+
conversationId,
|
|
2546
|
+
workId: request.params.workId,
|
|
2547
|
+
modelId: input.modelId,
|
|
2548
|
+
scope: input.scope,
|
|
2549
|
+
instruction: resolvedInstruction,
|
|
2550
|
+
excludeConversationMessageId: input.currentMessageId
|
|
2551
|
+
});
|
|
2552
|
+
if (inspection.action === "warn") {
|
|
2553
|
+
const prepared = await ai.prepareConversationContext({
|
|
2554
|
+
conversationId,
|
|
2555
|
+
workId: request.params.workId,
|
|
2556
|
+
modelId: input.modelId,
|
|
2557
|
+
scope: input.scope,
|
|
2558
|
+
instruction: resolvedInstruction,
|
|
2559
|
+
excludeConversationMessageId: input.currentMessageId
|
|
2560
|
+
});
|
|
2561
|
+
startStream();
|
|
2562
|
+
sendEvent("ready", { streaming: true, idempotencyKey });
|
|
2563
|
+
sendEvent("context", {
|
|
2564
|
+
...prepared,
|
|
2565
|
+
conversation: redactAiConversation({
|
|
2566
|
+
...store.getAiConversationSummary(conversationId),
|
|
2567
|
+
contextWarningPending: true
|
|
2568
|
+
}, permissions)
|
|
2569
|
+
});
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2372
2572
|
}
|
|
2373
|
-
const conversationId = String(conversation.id);
|
|
2374
|
-
const permissions = requestPermissions(request, request.params.workId);
|
|
2375
|
-
const prepared = await ai.prepareConversationContext({
|
|
2376
|
-
conversationId,
|
|
2377
|
-
workId: request.params.workId,
|
|
2378
|
-
modelId: input.modelId,
|
|
2379
|
-
scope: input.scope,
|
|
2380
|
-
instruction: resolvedInstruction,
|
|
2381
|
-
excludeConversationMessageId: input.currentMessageId
|
|
2382
|
-
});
|
|
2383
|
-
sendEvent("context", {
|
|
2384
|
-
...prepared,
|
|
2385
|
-
conversation: redactAiConversation({
|
|
2386
|
-
...store.getAiConversationSummary(conversationId),
|
|
2387
|
-
contextWarningPending: prepared.action === "warn"
|
|
2388
|
-
}, permissions)
|
|
2389
|
-
});
|
|
2390
|
-
if (prepared.action === "warn")
|
|
2391
|
-
return;
|
|
2392
2573
|
const resolvedScope = input.currentMessageId
|
|
2393
2574
|
? input.scope
|
|
2394
2575
|
: ai.resolveInstructionMentions({
|
|
@@ -2402,17 +2583,71 @@ export function createRuntime(options) {
|
|
|
2402
2583
|
...(resolvedScope.characterIds ?? []),
|
|
2403
2584
|
...(resolvedScope.mentionCharacterIds ?? [])
|
|
2404
2585
|
])];
|
|
2405
|
-
const
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2586
|
+
const begun = store.beginAiConversationStreamRequest({
|
|
2587
|
+
workId: request.params.workId,
|
|
2588
|
+
conversationId,
|
|
2589
|
+
actorScope,
|
|
2590
|
+
idempotencyKey,
|
|
2591
|
+
requestHash,
|
|
2592
|
+
userMessage: {
|
|
2409
2593
|
content: input.instruction,
|
|
2410
2594
|
citations,
|
|
2595
|
+
...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
|
|
2411
2596
|
...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
streamRequestId = begun.request.id;
|
|
2600
|
+
startStream();
|
|
2601
|
+
sendEvent("ready", { streaming: begun.disposition === "started", idempotencyKey });
|
|
2602
|
+
if (begun.disposition !== "started") {
|
|
2603
|
+
streamRequestFinished = true;
|
|
2604
|
+
if (begun.userMessage) {
|
|
2605
|
+
sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions), replayed: true });
|
|
2606
|
+
}
|
|
2607
|
+
if (begun.request.status === "completed" && begun.assistantMessage) {
|
|
2608
|
+
const content = String(begun.assistantMessage.content ?? "");
|
|
2609
|
+
if (content)
|
|
2610
|
+
sendEvent("delta", { delta: content, replayed: true });
|
|
2611
|
+
sendEvent("complete", {
|
|
2612
|
+
replayed: true,
|
|
2613
|
+
conversationId,
|
|
2614
|
+
conversationTitle: store.getAiConversationSummary(conversationId).title,
|
|
2615
|
+
messageId: begun.assistantMessage.id,
|
|
2616
|
+
messageCreatedAt: begun.assistantMessage.createdAt
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
else {
|
|
2620
|
+
sendEvent("request_status", {
|
|
2621
|
+
code: begun.request.status === "in_progress"
|
|
2622
|
+
? "AI_IDEMPOTENT_REQUEST_IN_PROGRESS"
|
|
2623
|
+
: "AI_IDEMPOTENT_REQUEST_TERMINAL",
|
|
2624
|
+
message: begun.request.status === "in_progress"
|
|
2625
|
+
? "相同请求正在处理中,请等待当前响应结束"
|
|
2626
|
+
: "相同请求已经结束,不会再次调用 AI",
|
|
2627
|
+
status: begun.request.status,
|
|
2628
|
+
terminalReason: begun.request.terminalReason
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
const currentMessageId = String(begun.userMessage?.id ?? input.currentMessageId ?? "");
|
|
2634
|
+
if (begun.userMessage)
|
|
2635
|
+
sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions) });
|
|
2636
|
+
const prepared = await ai.prepareConversationContext({
|
|
2637
|
+
conversationId,
|
|
2638
|
+
workId: request.params.workId,
|
|
2639
|
+
modelId: input.modelId,
|
|
2640
|
+
scope: input.scope,
|
|
2641
|
+
instruction: resolvedInstruction,
|
|
2642
|
+
excludeConversationMessageId: currentMessageId
|
|
2643
|
+
}, { skipWarning: true });
|
|
2644
|
+
sendEvent("context", {
|
|
2645
|
+
...prepared,
|
|
2646
|
+
conversation: redactAiConversation({
|
|
2647
|
+
...store.getAiConversationSummary(conversationId),
|
|
2648
|
+
contextWarningPending: prepared.action === "warn"
|
|
2649
|
+
}, permissions)
|
|
2650
|
+
});
|
|
2416
2651
|
const suggestion = await ai.createStreamingChat({
|
|
2417
2652
|
workId: request.params.workId,
|
|
2418
2653
|
instruction: resolvedInstruction,
|
|
@@ -2428,6 +2663,13 @@ export function createRuntime(options) {
|
|
|
2428
2663
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
|
2429
2664
|
...(input.parameters ? { parameters: input.parameters } : {})
|
|
2430
2665
|
}, (delta) => sendEvent("delta", { delta }));
|
|
2666
|
+
const assistantMessageId = typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
|
|
2667
|
+
? String(suggestion.conversationMessage.id ?? "")
|
|
2668
|
+
: "";
|
|
2669
|
+
if (!stopping) {
|
|
2670
|
+
store.finishAiConversationStreamRequest(streamRequestId, "completed", "completed", assistantMessageId || undefined);
|
|
2671
|
+
}
|
|
2672
|
+
streamRequestFinished = true;
|
|
2431
2673
|
sendEvent("complete", {
|
|
2432
2674
|
suggestionId: suggestion.id,
|
|
2433
2675
|
callId: suggestion.callId,
|
|
@@ -2450,6 +2692,18 @@ export function createRuntime(options) {
|
|
|
2450
2692
|
});
|
|
2451
2693
|
}
|
|
2452
2694
|
catch (error) {
|
|
2695
|
+
if (streamRequestId && !streamRequestFinished && !stopping) {
|
|
2696
|
+
const code = error instanceof AppError ? error.code : "AI_STREAM_FAILED";
|
|
2697
|
+
const status = code === "AI_STREAM_IDLE_TIMEOUT"
|
|
2698
|
+
? "timed_out"
|
|
2699
|
+
: code === "AI_STREAM_REQUEST_CANCELLED" || controller.signal.aborted
|
|
2700
|
+
? "cancelled"
|
|
2701
|
+
: "failed";
|
|
2702
|
+
store.finishAiConversationStreamRequest(streamRequestId, status, code);
|
|
2703
|
+
streamRequestFinished = true;
|
|
2704
|
+
}
|
|
2705
|
+
if (!response.headersSent)
|
|
2706
|
+
throw error;
|
|
2453
2707
|
if (!controller.signal.aborted) {
|
|
2454
2708
|
logger.error("ai.stream.failed", {
|
|
2455
2709
|
workId: request.params.workId,
|
|
@@ -2459,7 +2713,13 @@ export function createRuntime(options) {
|
|
|
2459
2713
|
}
|
|
2460
2714
|
}
|
|
2461
2715
|
finally {
|
|
2462
|
-
if (!
|
|
2716
|
+
if (streamRequestId && !streamRequestFinished && !stopping) {
|
|
2717
|
+
store.finishAiConversationStreamRequest(streamRequestId, "cancelled", "stream_closed");
|
|
2718
|
+
streamRequestFinished = true;
|
|
2719
|
+
}
|
|
2720
|
+
if (stopping)
|
|
2721
|
+
streamRequestFinished = true;
|
|
2722
|
+
if (response.headersSent && !response.writableEnded && !response.destroyed)
|
|
2463
2723
|
response.end();
|
|
2464
2724
|
}
|
|
2465
2725
|
});
|
|
@@ -2500,7 +2760,7 @@ export function createRuntime(options) {
|
|
|
2500
2760
|
});
|
|
2501
2761
|
app.get("/api/works/:workId/search", async (request, response) => {
|
|
2502
2762
|
const query = parse(z.object({
|
|
2503
|
-
q: z.string().trim().min(1).max(
|
|
2763
|
+
q: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
|
|
2504
2764
|
type: z.enum(HYBRID_SEARCH_TYPES).optional(),
|
|
2505
2765
|
limit: z.coerce.number().int().min(1).max(100).optional()
|
|
2506
2766
|
}).strict(), request.query);
|
|
@@ -2511,8 +2771,13 @@ export function createRuntime(options) {
|
|
|
2511
2771
|
includeAgentHistory: permissions["ai-chat"] !== "none"
|
|
2512
2772
|
}));
|
|
2513
2773
|
});
|
|
2774
|
+
app.head("/api/works/:workId/export", (request, response) => {
|
|
2775
|
+
parse(z.enum(["epub"]), request.query.format ?? "epub");
|
|
2776
|
+
store.getWork(request.params.workId);
|
|
2777
|
+
noContent(response);
|
|
2778
|
+
});
|
|
2514
2779
|
app.get("/api/works/:workId/export", async (request, response) => {
|
|
2515
|
-
const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
|
|
2780
|
+
const format = parse(z.enum(["json", "txt", "markdown", "docx", "epub"]), request.query.format ?? "json");
|
|
2516
2781
|
if (format === "json") {
|
|
2517
2782
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
|
|
2518
2783
|
data(response, store.exportWork(request.params.workId));
|
|
@@ -2538,6 +2803,11 @@ export function createRuntime(options) {
|
|
|
2538
2803
|
response.send(await store.exportDocx(request.params.workId));
|
|
2539
2804
|
return;
|
|
2540
2805
|
}
|
|
2806
|
+
if (format === "epub") {
|
|
2807
|
+
const exported = await store.exportEpub(request.params.workId);
|
|
2808
|
+
await sendEpub(response, exported.archive, exported.title, `novel-${request.params.workId}`);
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2541
2811
|
response.type("text/plain");
|
|
2542
2812
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
|
|
2543
2813
|
response.send(store.exportText(request.params.workId, format));
|
|
@@ -2650,7 +2920,7 @@ export function createRuntime(options) {
|
|
|
2650
2920
|
logger.error("http.request.application_error", logFields);
|
|
2651
2921
|
else
|
|
2652
2922
|
logger.warn("http.request.application_error", logFields);
|
|
2653
|
-
if (error.code === "LOGIN_LOCKED" && error.details && typeof error.details === "object") {
|
|
2923
|
+
if ((error.code === "LOGIN_LOCKED" || error.status === 429) && error.details && typeof error.details === "object") {
|
|
2654
2924
|
const retryAfterSeconds = Number(error.details.retryAfterSeconds);
|
|
2655
2925
|
if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
2656
2926
|
response.setHeader("Retry-After", String(retryAfterSeconds));
|
|
@@ -2682,6 +2952,9 @@ export function createRuntime(options) {
|
|
|
2682
2952
|
logger.info("runtime.closing");
|
|
2683
2953
|
backups.dispose();
|
|
2684
2954
|
ai.dispose();
|
|
2955
|
+
const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
|
|
2956
|
+
if (cancelledStreamRequests > 0)
|
|
2957
|
+
logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
|
|
2685
2958
|
}
|
|
2686
2959
|
closePromise = (async () => {
|
|
2687
2960
|
try {
|