@musnows/scriverse 0.7.2 → 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 +8 -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 +926 -354
- package/dist/ai.js.map +1 -1
- package/dist/app.js +498 -80
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +28 -12
- package/dist/attachment-storage.js.map +1 -1
- package/dist/backup-encryption.js +129 -0
- package/dist/backup-encryption.js.map +1 -0
- 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/collaboration-presence.js +200 -18
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +335 -3
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +1 -1
- package/dist/docx-export.js.map +1 -1
- package/dist/domain.js +18 -0
- package/dist/domain.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/image-metadata.js +17 -2
- package/dist/image-metadata.js.map +1 -1
- package/dist/presence-store.js +160 -0
- package/dist/presence-store.js.map +1 -0
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-context-meter.js +27 -0
- package/dist/public/ai-mentions.js +14 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +3587 -559
- package/dist/public/background-task-center.js +1 -1
- package/dist/public/chapter-editor-virtualization.js +52 -0
- 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 +161 -31
- 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/presence-client-id.d.ts +10 -0
- package/dist/public/presence-client-id.js +31 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/s3-backup-ui.d.ts +10 -0
- package/dist/public/s3-backup-ui.js +29 -0
- package/dist/public/setting-filters.d.ts +16 -0
- package/dist/public/setting-filters.js +19 -0
- package/dist/public/styles.css +522 -11
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/s3-backup.js +197 -12
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +86 -21
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +38 -18
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +899 -104
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +35 -0
- package/dist/upload-limits.js.map +1 -0
- package/dist/user-auth.js +56 -10
- 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 +4 -2
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";
|
|
19
|
-
import {
|
|
22
|
+
import { EPUB_MIME_TYPE, epubContentDisposition } from "./epub-export.js";
|
|
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";
|
|
@@ -34,11 +38,15 @@ import { currentRequestActor, runWithRequestActor } from "./request-context.js";
|
|
|
34
38
|
import { S3BackupManager } from "./s3-backup.js";
|
|
35
39
|
import { APP_VERSION } from "./version.js";
|
|
36
40
|
import { ReleaseUpdateChecker } from "./release-update.js";
|
|
41
|
+
import { DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
|
|
37
42
|
import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
38
|
-
import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
|
|
43
|
+
import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
|
|
44
|
+
import { PresenceStore } from "./presence-store.js";
|
|
39
45
|
import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
|
|
40
46
|
const nonEmpty = z.string().trim().min(1);
|
|
41
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, "幂等键只能包含英文字母、数字、点、下划线、冒号和短横线");
|
|
42
50
|
const optionalStrings = z.array(z.string()).optional();
|
|
43
51
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
44
52
|
const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
|
|
@@ -47,6 +55,34 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
|
|
|
47
55
|
const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
|
|
48
56
|
const maximumImportedTextLength = 20_000_000;
|
|
49
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
|
+
}
|
|
70
|
+
function assertImageUploadSize(byteLength, maximumBytes, message) {
|
|
71
|
+
if (byteLength <= maximumBytes)
|
|
72
|
+
return;
|
|
73
|
+
throw new AppError(413, "IMAGE_TOO_LARGE", message);
|
|
74
|
+
}
|
|
75
|
+
function uploadSizeError(pathname, limits) {
|
|
76
|
+
if (pathname === "/api/auth/avatar")
|
|
77
|
+
return { code: "IMAGE_TOO_LARGE", message: `头像图片不能超过 ${formatUploadLimit(limits.avatarBytes)}` };
|
|
78
|
+
if (/^\/api\/works\/[^/]+\/cover$/u.test(pathname)) {
|
|
79
|
+
return { code: "IMAGE_TOO_LARGE", message: `封面图片不能超过 ${formatUploadLimit(limits.coverBytes)}` };
|
|
80
|
+
}
|
|
81
|
+
if (/^\/api\/works\/[^/]+\/attachments$/u.test(pathname)) {
|
|
82
|
+
return { code: "ATTACHMENT_TOO_LARGE", message: `图片附件不能超过 ${formatUploadLimit(limits.attachmentBytes)}` };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
50
86
|
const captchaFields = {
|
|
51
87
|
captchaId: z.string().trim().min(1).max(200),
|
|
52
88
|
captchaAnswer: z.string().trim().min(1).max(16)
|
|
@@ -153,7 +189,7 @@ const settingSchema = z.object({
|
|
|
153
189
|
const globalReplaceSchema = z.object({
|
|
154
190
|
find: z.string().min(1).max(500),
|
|
155
191
|
replacement: z.string().max(200_000),
|
|
156
|
-
scope: z.enum(["prose", "settings", "prose-and-settings"])
|
|
192
|
+
scope: z.enum(["prose", "settings", "prose-and-settings"])
|
|
157
193
|
}).strict();
|
|
158
194
|
const draftSchema = z.object({
|
|
159
195
|
draftType: z.enum(["prose", "setting"]),
|
|
@@ -446,6 +482,12 @@ const s3BackupRunQuerySchema = z.object({
|
|
|
446
482
|
afterSequence: z.coerce.number().int().min(0).optional(),
|
|
447
483
|
limit: z.coerce.number().int().min(1).max(100).optional()
|
|
448
484
|
}).strict();
|
|
485
|
+
const s3BackupEncryptionSchema = z.object({
|
|
486
|
+
enabled: z.boolean()
|
|
487
|
+
}).strict();
|
|
488
|
+
const s3BackupEncryptionConfirmationSchema = z.object({
|
|
489
|
+
confirmationToken: z.string().regex(/^[A-Za-z0-9_-]{43}$/u, "备份加密确认令牌格式无效")
|
|
490
|
+
}).strict();
|
|
449
491
|
const aiToolCallResultSchema = z.object({
|
|
450
492
|
id: z.string().min(1).max(300),
|
|
451
493
|
name: z.string().min(1).max(200),
|
|
@@ -517,7 +559,8 @@ const contextSchema = z.object({
|
|
|
517
559
|
includeBookSummary: z.boolean().optional(),
|
|
518
560
|
includeSettingInfo: z.boolean().optional()
|
|
519
561
|
});
|
|
520
|
-
|
|
562
|
+
/** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
|
|
563
|
+
export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
|
|
521
564
|
const relationshipSourceRefSchema = z.object({
|
|
522
565
|
sourceType: z.string().trim().min(1).max(50).regex(/^[a-z][a-z-]*$/u),
|
|
523
566
|
sourceId: identifier,
|
|
@@ -561,7 +604,7 @@ const relationshipAnalysisScopeSchema = z.object({
|
|
|
561
604
|
});
|
|
562
605
|
const analysisTaskSchema = z.union([
|
|
563
606
|
z.object({ taskType: z.literal("relationship-analysis"), scope: relationshipAnalysisScopeSchema.optional(), modelId: identifier.optional() }).strict(),
|
|
564
|
-
z.object({ taskType:
|
|
607
|
+
z.object({ taskType: creatableAnalysisTaskTypeSchema, scope: jsonObject.optional(), modelId: identifier.optional() }).strict().superRefine((input, context) => {
|
|
565
608
|
if (input.scope?.includeAllSettings !== undefined) {
|
|
566
609
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ["scope", "includeAllSettings"], message: "包含所有设定仅支持人物关系分析" });
|
|
567
610
|
}
|
|
@@ -582,12 +625,53 @@ const analysisTaskSchema = z.union([
|
|
|
582
625
|
}
|
|
583
626
|
})
|
|
584
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
|
+
});
|
|
656
|
+
export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
|
|
585
657
|
function data(response, value, status = 200) {
|
|
586
658
|
response.status(status).json({ data: value });
|
|
587
659
|
}
|
|
588
660
|
function noContent(response) {
|
|
589
661
|
response.status(204).end();
|
|
590
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
|
+
}
|
|
591
675
|
function parse(schema, value) {
|
|
592
676
|
return schema.parse(value);
|
|
593
677
|
}
|
|
@@ -790,11 +874,18 @@ function redactSuggestion(record, permissions) {
|
|
|
790
874
|
};
|
|
791
875
|
}
|
|
792
876
|
function redactAiConversationMessage(item, permissions) {
|
|
793
|
-
if (permissions.prose !== "none")
|
|
794
|
-
return item;
|
|
795
877
|
const message = recordValue(item);
|
|
796
878
|
if (!message)
|
|
797
879
|
return item;
|
|
880
|
+
if (permissions.prose !== "none") {
|
|
881
|
+
if (permissions.characters !== "none")
|
|
882
|
+
return item;
|
|
883
|
+
const metadata = recordValue(message.metadata);
|
|
884
|
+
if (!metadata || !("mentionCharacterIds" in metadata))
|
|
885
|
+
return item;
|
|
886
|
+
const { mentionCharacterIds: _mentionCharacterIds, ...readableMetadata } = metadata;
|
|
887
|
+
return { ...message, metadata: readableMetadata };
|
|
888
|
+
}
|
|
798
889
|
return {
|
|
799
890
|
...message,
|
|
800
891
|
content: proseRestrictedPlaceholder,
|
|
@@ -807,25 +898,25 @@ function redactAiConversationMessage(item, permissions) {
|
|
|
807
898
|
function redactAiConversation(record, permissions) {
|
|
808
899
|
const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
|
|
809
900
|
const scopedRecord = redactAiCallContext(readableRecord, permissions);
|
|
810
|
-
if (permissions.prose !== "none")
|
|
811
|
-
return scopedRecord;
|
|
812
901
|
const result = {
|
|
813
|
-
...scopedRecord
|
|
814
|
-
title: proseRestrictedPlaceholder
|
|
902
|
+
...scopedRecord
|
|
815
903
|
};
|
|
816
|
-
if (
|
|
817
|
-
result.preview = proseRestrictedPlaceholder;
|
|
818
|
-
}
|
|
819
|
-
if (Array.isArray(result.messages)) {
|
|
904
|
+
if ((permissions.prose === "none" || permissions.characters === "none") && Array.isArray(result.messages)) {
|
|
820
905
|
result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
|
|
821
906
|
}
|
|
822
907
|
const messagesPage = recordValue(result.messagesPage);
|
|
823
|
-
if (messagesPage && Array.isArray(messagesPage.items)) {
|
|
908
|
+
if ((permissions.prose === "none" || permissions.characters === "none") && messagesPage && Array.isArray(messagesPage.items)) {
|
|
824
909
|
result.messagesPage = {
|
|
825
910
|
...messagesPage,
|
|
826
911
|
items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
|
|
827
912
|
};
|
|
828
913
|
}
|
|
914
|
+
if (permissions.prose !== "none")
|
|
915
|
+
return result;
|
|
916
|
+
result.title = proseRestrictedPlaceholder;
|
|
917
|
+
if (typeof result.preview === "string" && result.preview.length > 0) {
|
|
918
|
+
result.preview = proseRestrictedPlaceholder;
|
|
919
|
+
}
|
|
829
920
|
return { ...result, restricted: true };
|
|
830
921
|
}
|
|
831
922
|
/** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
|
|
@@ -843,7 +934,9 @@ export function publicAiStreamError(error) {
|
|
|
843
934
|
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
844
935
|
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
845
936
|
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
846
|
-
...(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 } : {})
|
|
847
940
|
};
|
|
848
941
|
}
|
|
849
942
|
return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
|
|
@@ -865,6 +958,7 @@ function redactVersionSnapshots(value, mapper) {
|
|
|
865
958
|
});
|
|
866
959
|
}
|
|
867
960
|
export function createRuntime(options) {
|
|
961
|
+
const uploadLimits = options.uploadLimits ?? DEFAULT_IMAGE_UPLOAD_LIMITS;
|
|
868
962
|
logger.info("runtime.initializing", {
|
|
869
963
|
databasePath: options.databasePath,
|
|
870
964
|
serveUi: options.serveUi ?? true,
|
|
@@ -878,19 +972,35 @@ export function createRuntime(options) {
|
|
|
878
972
|
const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
|
|
879
973
|
? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
|
|
880
974
|
: null;
|
|
881
|
-
const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"));
|
|
975
|
+
const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"), uploadLimits.attachmentBytes);
|
|
882
976
|
mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
|
|
883
977
|
const auth = new UserAuthService(database);
|
|
884
|
-
const collaborationPresence = new CollaborationPresence();
|
|
885
|
-
const
|
|
978
|
+
const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
|
|
979
|
+
const publishCollaborativeChange = (workId, pageKey, options = {}) => {
|
|
886
980
|
const actor = currentRequestActor();
|
|
887
|
-
if (!actor || !workId || !
|
|
981
|
+
if (!actor || !workId || !pageKey)
|
|
888
982
|
return;
|
|
889
|
-
collaborationPresence.publishChange(workId,
|
|
983
|
+
collaborationPresence.publishChange(workId, pageKey, {
|
|
890
984
|
userId: actor.userId,
|
|
891
985
|
displayName: actor.displayName
|
|
892
|
-
});
|
|
986
|
+
}, options);
|
|
987
|
+
};
|
|
988
|
+
const publishEditorChange = (workId, chapterId, options = {}) => {
|
|
989
|
+
if (!chapterId)
|
|
990
|
+
return;
|
|
991
|
+
publishCollaborativeChange(workId, editorPageKey(chapterId), options);
|
|
893
992
|
};
|
|
993
|
+
const publishEntityChange = (workId, module, resourceId, options = {}) => {
|
|
994
|
+
if (!resourceId)
|
|
995
|
+
return;
|
|
996
|
+
publishCollaborativeChange(workId, entityEditorPageKey(module, resourceId), options);
|
|
997
|
+
};
|
|
998
|
+
const publishModuleChange = (workId, module, options = {}) => {
|
|
999
|
+
if (!module)
|
|
1000
|
+
return;
|
|
1001
|
+
publishCollaborativeChange(workId, modulePageKey(module), options);
|
|
1002
|
+
};
|
|
1003
|
+
const deletedPageChange = { action: "delete", pageDeleted: true };
|
|
894
1004
|
const getDevelopmentUser = () => options.devAuthBypass
|
|
895
1005
|
? auth.listUsers().find((user) => user.status === "active") ?? null
|
|
896
1006
|
: null;
|
|
@@ -942,7 +1052,9 @@ export function createRuntime(options) {
|
|
|
942
1052
|
timeoutMs: options.releaseCheckTimeoutMs,
|
|
943
1053
|
retries: options.releaseCheckRetries
|
|
944
1054
|
});
|
|
945
|
-
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.
|
|
1055
|
+
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
|
|
1056
|
+
? undefined
|
|
1057
|
+
: options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
|
|
946
1058
|
const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
|
|
947
1059
|
const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
|
|
948
1060
|
const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
|
|
@@ -955,7 +1067,7 @@ export function createRuntime(options) {
|
|
|
955
1067
|
read: requiredModules,
|
|
956
1068
|
write: ["ai-analysis"]
|
|
957
1069
|
}, false, actor?.allowAdminAccess ?? false);
|
|
958
|
-
}, attachmentStorage);
|
|
1070
|
+
}, attachmentStorage, { interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS });
|
|
959
1071
|
const app = express();
|
|
960
1072
|
enforceCaseInsensitiveRouting(app);
|
|
961
1073
|
const upload = multer({
|
|
@@ -964,18 +1076,18 @@ export function createRuntime(options) {
|
|
|
964
1076
|
});
|
|
965
1077
|
const coverUpload = multer({
|
|
966
1078
|
storage: multer.memoryStorage(),
|
|
967
|
-
limits: { fileSize:
|
|
1079
|
+
limits: { fileSize: uploadLimits.coverBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
|
|
968
1080
|
});
|
|
969
1081
|
const avatarUpload = multer({
|
|
970
1082
|
storage: multer.memoryStorage(),
|
|
971
|
-
limits: { fileSize:
|
|
1083
|
+
limits: { fileSize: uploadLimits.avatarBytes + 1, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
|
|
972
1084
|
});
|
|
973
1085
|
const attachmentUpload = multer({
|
|
974
1086
|
storage: multer.diskStorage({
|
|
975
1087
|
destination: attachmentStorage.temporaryDirectory,
|
|
976
1088
|
filename: (_request, _file, callback) => callback(null, randomUUID())
|
|
977
1089
|
}),
|
|
978
|
-
limits: { fileSize:
|
|
1090
|
+
limits: { fileSize: uploadLimits.attachmentBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
|
|
979
1091
|
});
|
|
980
1092
|
app.disable("x-powered-by");
|
|
981
1093
|
const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
|
|
@@ -993,7 +1105,8 @@ export function createRuntime(options) {
|
|
|
993
1105
|
version: APP_VERSION,
|
|
994
1106
|
protocol: "openai-chat-completions",
|
|
995
1107
|
protocols: [...AI_PROVIDER_PROTOCOLS],
|
|
996
|
-
development: options.developmentServer === true
|
|
1108
|
+
development: options.developmentServer === true,
|
|
1109
|
+
uploadLimits
|
|
997
1110
|
});
|
|
998
1111
|
});
|
|
999
1112
|
app.get("/api/update-check", async (_request, response) => {
|
|
@@ -1086,7 +1199,8 @@ export function createRuntime(options) {
|
|
|
1086
1199
|
if (!request.authUser)
|
|
1087
1200
|
throw new AppError(401, "AUTH_REQUIRED", "请先登录");
|
|
1088
1201
|
if (!request.file)
|
|
1089
|
-
throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或
|
|
1202
|
+
throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG、WebP 或 GIF 头像");
|
|
1203
|
+
assertImageUploadSize(request.file.buffer.byteLength, uploadLimits.avatarBytes, `头像图片不能超过 ${formatUploadLimit(uploadLimits.avatarBytes)}`);
|
|
1090
1204
|
try {
|
|
1091
1205
|
const metadata = readRasterImageMetadata(request.file.buffer);
|
|
1092
1206
|
const updated = database.transaction(() => {
|
|
@@ -1172,6 +1286,23 @@ export function createRuntime(options) {
|
|
|
1172
1286
|
const pagination = parsePagination(request.query);
|
|
1173
1287
|
data(response, pagination ? store.listWorksPage(pagination) : store.listWorks());
|
|
1174
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
|
+
});
|
|
1175
1306
|
app.post("/api/works", (request, response) => data(response, store.createWork(parse(workSchema, request.body)), 201));
|
|
1176
1307
|
app.post("/api/works/import", upload.single("file"), async (request, response) => {
|
|
1177
1308
|
if (!request.file)
|
|
@@ -1256,7 +1387,6 @@ export function createRuntime(options) {
|
|
|
1256
1387
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
1257
1388
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1258
1389
|
store.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
1259
|
-
await cleanupAttachments();
|
|
1260
1390
|
noContent(response);
|
|
1261
1391
|
});
|
|
1262
1392
|
app.get("/api/works/:workId/cover", (request, response) => {
|
|
@@ -1271,8 +1401,11 @@ export function createRuntime(options) {
|
|
|
1271
1401
|
if (!request.file)
|
|
1272
1402
|
throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
|
|
1273
1403
|
const bytes = request.file.buffer;
|
|
1404
|
+
assertImageUploadSize(bytes.byteLength, uploadLimits.coverBytes, `封面图片不能超过 ${formatUploadLimit(uploadLimits.coverBytes)}`);
|
|
1274
1405
|
try {
|
|
1275
1406
|
const metadata = readRasterImageMetadata(bytes);
|
|
1407
|
+
if (metadata.mimeType === "image/gif")
|
|
1408
|
+
throw new AppError(415, "UNSUPPORTED_COVER_FORMAT", "封面不支持 GIF 图片");
|
|
1276
1409
|
const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
|
|
1277
1410
|
data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
|
|
1278
1411
|
}
|
|
@@ -1333,11 +1466,31 @@ export function createRuntime(options) {
|
|
|
1333
1466
|
? store.listVolumeChaptersPage(request.params.volumeId, pagination)
|
|
1334
1467
|
: store.listVolumeChapters(request.params.volumeId));
|
|
1335
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
|
+
});
|
|
1336
1479
|
app.delete("/api/volumes/:volumeId", (request, response) => {
|
|
1337
1480
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1338
1481
|
store.deleteVolume(request.params.volumeId, input.expectedVersionNo);
|
|
1339
1482
|
noContent(response);
|
|
1340
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
|
+
});
|
|
1341
1494
|
app.post("/api/works/:workId/chapters", (request, response) => {
|
|
1342
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);
|
|
1343
1496
|
data(response, store.createChapter(request.params.workId, input), 201);
|
|
@@ -1348,16 +1501,22 @@ export function createRuntime(options) {
|
|
|
1348
1501
|
? store.listDeletedChaptersPage(request.params.workId, pagination)
|
|
1349
1502
|
: store.listDeletedChapters(request.params.workId));
|
|
1350
1503
|
});
|
|
1504
|
+
app.get("/api/works/:workId/recycle-bin", (request, response) => {
|
|
1505
|
+
data(response, store.getRecycleBin(request.params.workId));
|
|
1506
|
+
});
|
|
1351
1507
|
app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
|
|
1352
1508
|
app.patch("/api/chapters/:chapterId", (request, response) => {
|
|
1353
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);
|
|
1354
1510
|
const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
|
|
1355
1511
|
const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
|
|
1512
|
+
publishEditorChange(String(chapter.workId), String(chapter.id));
|
|
1356
1513
|
data(response, chapter);
|
|
1357
1514
|
});
|
|
1358
1515
|
app.delete("/api/chapters/:chapterId", (request, response) => {
|
|
1359
1516
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1517
|
+
const chapter = store.getChapter(request.params.chapterId);
|
|
1360
1518
|
store.deleteChapter(request.params.chapterId, input.expectedVersionNo);
|
|
1519
|
+
publishEditorChange(String(chapter.workId), String(chapter.id), deletedPageChange);
|
|
1361
1520
|
noContent(response);
|
|
1362
1521
|
});
|
|
1363
1522
|
app.delete("/api/chapters/:chapterId/permanent", (request, response) => {
|
|
@@ -1424,6 +1583,9 @@ export function createRuntime(options) {
|
|
|
1424
1583
|
const pagination = parsePagination(request.query);
|
|
1425
1584
|
data(response, pagination ? store.listChapterOutlinesPage(request.params.workId, pagination) : store.listChapterOutlines(request.params.workId));
|
|
1426
1585
|
});
|
|
1586
|
+
app.get("/api/works/:workId/outline-board", (request, response) => {
|
|
1587
|
+
data(response, store.getChapterOutlineBoard(request.params.workId));
|
|
1588
|
+
});
|
|
1427
1589
|
app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
|
|
1428
1590
|
app.put("/api/chapters/:chapterId/outline", (request, response) => {
|
|
1429
1591
|
const { changeNote, expectedVersionNo, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
@@ -1435,6 +1597,13 @@ export function createRuntime(options) {
|
|
|
1435
1597
|
store.deleteChapterOutline(request.params.chapterId, input.expectedVersionNo);
|
|
1436
1598
|
noContent(response);
|
|
1437
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
|
+
});
|
|
1438
1607
|
app.get("/api/works/:workId/foreshadows", (request, response) => {
|
|
1439
1608
|
const query = parse(z.object({
|
|
1440
1609
|
status: z.enum(["all", "unresolved", "resolved"]).default("all"),
|
|
@@ -1518,11 +1687,14 @@ export function createRuntime(options) {
|
|
|
1518
1687
|
app.patch("/api/settings/:settingId", (request, response) => {
|
|
1519
1688
|
const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1520
1689
|
const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1690
|
+
publishEntityChange(String(setting.workId), "setting", String(setting.id));
|
|
1521
1691
|
data(response, setting);
|
|
1522
1692
|
});
|
|
1523
1693
|
app.delete("/api/settings/:settingId", (request, response) => {
|
|
1524
1694
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1695
|
+
const setting = store.getSetting(request.params.settingId);
|
|
1525
1696
|
store.deleteSetting(request.params.settingId, input.expectedVersionNo);
|
|
1697
|
+
publishEntityChange(String(setting.workId), "setting", String(setting.id), deletedPageChange);
|
|
1526
1698
|
noContent(response);
|
|
1527
1699
|
});
|
|
1528
1700
|
app.get("/api/works/:workId/characters", (request, response) => {
|
|
@@ -1548,6 +1720,7 @@ export function createRuntime(options) {
|
|
|
1548
1720
|
app.patch("/api/characters/:characterId", (request, response) => {
|
|
1549
1721
|
const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
|
|
1550
1722
|
const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1723
|
+
publishEntityChange(String(character.workId), "character", String(character.id));
|
|
1551
1724
|
data(response, redactCharacterLinks(character, requestPermissions(request)));
|
|
1552
1725
|
});
|
|
1553
1726
|
app.get("/api/characters/:characterId/versions", (request, response) => {
|
|
@@ -1563,7 +1736,9 @@ export function createRuntime(options) {
|
|
|
1563
1736
|
});
|
|
1564
1737
|
app.delete("/api/characters/:characterId", (request, response) => {
|
|
1565
1738
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1739
|
+
const character = store.getCharacter(request.params.characterId);
|
|
1566
1740
|
store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
|
|
1741
|
+
publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
|
|
1567
1742
|
noContent(response);
|
|
1568
1743
|
});
|
|
1569
1744
|
app.post("/api/characters/:characterId/merge", (request, response) => {
|
|
@@ -1596,11 +1771,17 @@ export function createRuntime(options) {
|
|
|
1596
1771
|
app.patch("/api/character-sections/:sectionId", (request, response) => {
|
|
1597
1772
|
const { changeNote, expectedVersionNo, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1598
1773
|
const section = store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1774
|
+
publishEntityChange(String(section.workId), "character", String(section.characterId));
|
|
1599
1775
|
data(response, section);
|
|
1600
1776
|
});
|
|
1601
1777
|
app.delete("/api/character-sections/:sectionId", (request, response) => {
|
|
1602
1778
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1779
|
+
const section = store.getCharacterProfileSection(request.params.sectionId);
|
|
1603
1780
|
store.deleteCharacterProfileSection(request.params.sectionId, input.expectedVersionNo);
|
|
1781
|
+
publishEntityChange(String(section.workId), "character", String(section.characterId), {
|
|
1782
|
+
action: "delete",
|
|
1783
|
+
label: "角色档案章节"
|
|
1784
|
+
});
|
|
1604
1785
|
noContent(response);
|
|
1605
1786
|
});
|
|
1606
1787
|
app.get("/api/character-sections/:sectionId/versions", (request, response) => {
|
|
@@ -1699,11 +1880,14 @@ export function createRuntime(options) {
|
|
|
1699
1880
|
app.patch("/api/races/:raceId", (request, response) => {
|
|
1700
1881
|
const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1701
1882
|
const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1883
|
+
publishEntityChange(String(race.workId), "race", String(race.id));
|
|
1702
1884
|
data(response, redactRaceMembers(race, requestPermissions(request)));
|
|
1703
1885
|
});
|
|
1704
1886
|
app.delete("/api/races/:raceId", (request, response) => {
|
|
1705
1887
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1888
|
+
const race = store.getRace(request.params.raceId);
|
|
1706
1889
|
store.deleteRace(request.params.raceId, input.expectedVersionNo);
|
|
1890
|
+
publishEntityChange(String(race.workId), "race", String(race.id), deletedPageChange);
|
|
1707
1891
|
noContent(response);
|
|
1708
1892
|
});
|
|
1709
1893
|
app.post("/api/races/:raceId/merge", (request, response) => {
|
|
@@ -1729,11 +1913,14 @@ export function createRuntime(options) {
|
|
|
1729
1913
|
app.patch("/api/organizations/:organizationId", (request, response) => {
|
|
1730
1914
|
const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1731
1915
|
const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1916
|
+
publishEntityChange(String(organization.workId), "organization", String(organization.id));
|
|
1732
1917
|
data(response, redactOrganizationMembers(organization, requestPermissions(request)));
|
|
1733
1918
|
});
|
|
1734
1919
|
app.delete("/api/organizations/:organizationId", (request, response) => {
|
|
1735
1920
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1921
|
+
const organization = store.getOrganization(request.params.organizationId);
|
|
1736
1922
|
store.deleteOrganization(request.params.organizationId, input.expectedVersionNo);
|
|
1923
|
+
publishEntityChange(String(organization.workId), "organization", String(organization.id), deletedPageChange);
|
|
1737
1924
|
noContent(response);
|
|
1738
1925
|
});
|
|
1739
1926
|
app.post("/api/organizations/:organizationId/merge", (request, response) => {
|
|
@@ -1754,11 +1941,14 @@ export function createRuntime(options) {
|
|
|
1754
1941
|
app.patch("/api/timeline-tracks/:trackId", (request, response) => {
|
|
1755
1942
|
const { changeNote, expectedVersionNo, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1756
1943
|
const track = store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1944
|
+
publishModuleChange(String(track.workId), "timeline");
|
|
1757
1945
|
data(response, track);
|
|
1758
1946
|
});
|
|
1759
1947
|
app.delete("/api/timeline-tracks/:trackId", (request, response) => {
|
|
1760
1948
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1949
|
+
const track = store.getTimelineTrack(request.params.trackId);
|
|
1761
1950
|
store.deleteTimelineTrack(request.params.trackId, input.expectedVersionNo);
|
|
1951
|
+
publishModuleChange(String(track.workId), "timeline", { action: "delete", label: "时间轴轨道" });
|
|
1762
1952
|
noContent(response);
|
|
1763
1953
|
});
|
|
1764
1954
|
app.get("/api/works/:workId/timeline", (request, response) => {
|
|
@@ -1786,6 +1976,7 @@ export function createRuntime(options) {
|
|
|
1786
1976
|
app.patch("/api/timeline/:eventId", (request, response) => {
|
|
1787
1977
|
const { changeNote, expectedVersionNo, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1788
1978
|
const event = store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1979
|
+
publishModuleChange(String(event.workId), "timeline");
|
|
1789
1980
|
data(response, event);
|
|
1790
1981
|
});
|
|
1791
1982
|
app.post("/api/timeline/:eventId/split", (request, response) => {
|
|
@@ -1803,7 +1994,9 @@ export function createRuntime(options) {
|
|
|
1803
1994
|
});
|
|
1804
1995
|
app.delete("/api/timeline/:eventId", (request, response) => {
|
|
1805
1996
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1997
|
+
const event = store.getTimelineEvent(request.params.eventId);
|
|
1806
1998
|
store.deleteTimelineEvent(request.params.eventId, input.expectedVersionNo);
|
|
1999
|
+
publishModuleChange(String(event.workId), "timeline", { action: "delete", label: "时间轴事件" });
|
|
1807
2000
|
noContent(response);
|
|
1808
2001
|
});
|
|
1809
2002
|
app.get("/api/works/:workId/relationships", (request, response) => {
|
|
@@ -1823,14 +2016,14 @@ export function createRuntime(options) {
|
|
|
1823
2016
|
app.patch("/api/relationships/:relationshipId", (request, response) => {
|
|
1824
2017
|
const { changeNote, expectedVersionNo, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1825
2018
|
const relationship = store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote, expectedVersionNo);
|
|
1826
|
-
|
|
2019
|
+
publishEntityChange(String(relationship.workId), "relationship", String(relationship.id));
|
|
1827
2020
|
data(response, relationship);
|
|
1828
2021
|
});
|
|
1829
2022
|
app.delete("/api/relationships/:relationshipId", (request, response) => {
|
|
1830
2023
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1831
2024
|
const relationship = store.getRelationship(request.params.relationshipId);
|
|
1832
2025
|
store.deleteRelationship(request.params.relationshipId, input.expectedVersionNo);
|
|
1833
|
-
|
|
2026
|
+
publishEntityChange(String(relationship.workId), "relationship", String(relationship.id), deletedPageChange);
|
|
1834
2027
|
noContent(response);
|
|
1835
2028
|
});
|
|
1836
2029
|
app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
|
|
@@ -1917,6 +2110,9 @@ export function createRuntime(options) {
|
|
|
1917
2110
|
data(response, ai.resumeAutoRun(request.params.workId));
|
|
1918
2111
|
});
|
|
1919
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
|
+
});
|
|
1920
2116
|
app.get("/api/tasks/:taskId/result", (request, response) => {
|
|
1921
2117
|
const task = store.getTaskResultPayload(request.params.taskId);
|
|
1922
2118
|
const permissions = requestPermissions(request);
|
|
@@ -1958,6 +2154,14 @@ export function createRuntime(options) {
|
|
|
1958
2154
|
data(response, redactTaskCharacterNames(ai.rerunTask(request.params.taskId, input.modelId), requestPermissions(request)), 201);
|
|
1959
2155
|
});
|
|
1960
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
|
+
});
|
|
1961
2165
|
app.post("/api/tasks/:taskId/relationship-changes/apply", (request, response) => {
|
|
1962
2166
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1963
2167
|
const applied = ai.applyRelationshipChangePreview(request.params.taskId);
|
|
@@ -1992,6 +2196,17 @@ export function createRuntime(options) {
|
|
|
1992
2196
|
app.patch("/api/platform/ui-settings", (request, response) => {
|
|
1993
2197
|
data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
|
|
1994
2198
|
});
|
|
2199
|
+
app.get("/api/platform/backups/encryption", (_request, response) => {
|
|
2200
|
+
data(response, backups.getEncryptionState());
|
|
2201
|
+
});
|
|
2202
|
+
app.post("/api/platform/backups/encryption", (request, response) => {
|
|
2203
|
+
const input = parse(s3BackupEncryptionSchema, request.body);
|
|
2204
|
+
data(response, backups.setEncryptionEnabled(input.enabled));
|
|
2205
|
+
});
|
|
2206
|
+
app.post("/api/platform/backups/encryption/confirm", (request, response) => {
|
|
2207
|
+
const input = parse(s3BackupEncryptionConfirmationSchema, request.body);
|
|
2208
|
+
data(response, backups.confirmEncryptionEnabled(input.confirmationToken));
|
|
2209
|
+
});
|
|
1995
2210
|
app.get("/api/platform/backups/targets", (_request, response) => data(response, backups.listTargets()));
|
|
1996
2211
|
app.post("/api/platform/backups/targets", (request, response) => {
|
|
1997
2212
|
data(response, backups.createTarget(parse(s3BackupTargetBaseSchema, request.body)), 201);
|
|
@@ -2084,10 +2299,26 @@ export function createRuntime(options) {
|
|
|
2084
2299
|
const permissions = requestPermissions(request, String(conversation.workId));
|
|
2085
2300
|
data(response, redactAiConversation(conversation, permissions));
|
|
2086
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
|
+
});
|
|
2087
2310
|
app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
|
|
2088
|
-
const input = parse(z.object({
|
|
2089
|
-
|
|
2090
|
-
|
|
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);
|
|
2091
2322
|
data(response, redactAiConversation(forked, permissions), 201);
|
|
2092
2323
|
});
|
|
2093
2324
|
app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
|
|
@@ -2119,6 +2350,9 @@ export function createRuntime(options) {
|
|
|
2119
2350
|
outputTokens: z.number().int().min(0).max(10_000_000).optional(),
|
|
2120
2351
|
cacheHitPercent: z.number().min(0).max(100).optional(),
|
|
2121
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(),
|
|
2122
2356
|
toolCalls: z.array(aiToolCallResultSchema).max(12).optional(),
|
|
2123
2357
|
processSteps: z.array(aiProcessStepSchema).max(50).optional()
|
|
2124
2358
|
}).optional()
|
|
@@ -2179,13 +2413,19 @@ export function createRuntime(options) {
|
|
|
2179
2413
|
ai.deleteProvider(request.params.providerId);
|
|
2180
2414
|
noContent(response);
|
|
2181
2415
|
});
|
|
2182
|
-
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
|
+
});
|
|
2183
2420
|
app.get("/api/providers/:providerId/models", (request, response) => {
|
|
2184
2421
|
const pagination = parsePagination(request.query);
|
|
2185
2422
|
data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
|
|
2186
2423
|
});
|
|
2187
2424
|
app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
|
|
2188
|
-
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
|
+
});
|
|
2189
2429
|
app.get("/api/models/:modelId", (request, response) => data(response, ai.getModel(request.params.modelId)));
|
|
2190
2430
|
app.patch("/api/models/:modelId", (request, response) => data(response, ai.updateModel(request.params.modelId, parse(modelSchema.partial(), request.body))));
|
|
2191
2431
|
app.delete("/api/models/:modelId", (request, response) => {
|
|
@@ -2246,44 +2486,161 @@ export function createRuntime(options) {
|
|
|
2246
2486
|
conversationId: identifier.optional(),
|
|
2247
2487
|
currentMessageId: identifier.optional()
|
|
2248
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 }));
|
|
2249
2495
|
const citations = input.citations ?? [];
|
|
2250
2496
|
for (const citation of citations) {
|
|
2251
2497
|
if (store.getChapter(citation.chapterId).workId !== request.params.workId)
|
|
2252
2498
|
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
2253
2499
|
}
|
|
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);
|
|
2254
2515
|
const controller = new AbortController();
|
|
2255
2516
|
response.on("close", () => {
|
|
2256
2517
|
if (!response.writableEnded)
|
|
2257
2518
|
controller.abort(new Error("浏览器已中断流式请求"));
|
|
2258
2519
|
});
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
+
};
|
|
2265
2533
|
const sendEvent = (event, payload) => {
|
|
2534
|
+
if (streamRequestId && Date.now() - lastStreamLeaseTouchAt >= 30_000) {
|
|
2535
|
+
store.touchAiConversationStreamRequest(streamRequestId);
|
|
2536
|
+
lastStreamLeaseTouchAt = Date.now();
|
|
2537
|
+
}
|
|
2266
2538
|
if (!response.writableEnded && !response.destroyed)
|
|
2267
2539
|
response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
2268
2540
|
};
|
|
2269
|
-
sendEvent("ready", { streaming: true });
|
|
2270
2541
|
try {
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
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
|
+
}
|
|
2276
2572
|
}
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2573
|
+
const resolvedScope = input.currentMessageId
|
|
2574
|
+
? input.scope
|
|
2575
|
+
: ai.resolveInstructionMentions({
|
|
2576
|
+
workId: request.params.workId,
|
|
2577
|
+
taskType: "chat",
|
|
2578
|
+
instruction: resolvedInstruction,
|
|
2579
|
+
scope: input.scope,
|
|
2580
|
+
conversationId
|
|
2581
|
+
});
|
|
2582
|
+
const mentionCharacterIds = [...new Set([
|
|
2583
|
+
...(resolvedScope.characterIds ?? []),
|
|
2584
|
+
...(resolvedScope.mentionCharacterIds ?? [])
|
|
2585
|
+
])];
|
|
2586
|
+
const begun = store.beginAiConversationStreamRequest({
|
|
2587
|
+
workId: request.params.workId,
|
|
2588
|
+
conversationId,
|
|
2589
|
+
actorScope,
|
|
2590
|
+
idempotencyKey,
|
|
2591
|
+
requestHash,
|
|
2592
|
+
userMessage: {
|
|
2593
|
+
content: input.instruction,
|
|
2594
|
+
citations,
|
|
2595
|
+
...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
|
|
2596
|
+
...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
|
|
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) });
|
|
2279
2636
|
const prepared = await ai.prepareConversationContext({
|
|
2280
2637
|
conversationId,
|
|
2281
2638
|
workId: request.params.workId,
|
|
2282
2639
|
modelId: input.modelId,
|
|
2283
2640
|
scope: input.scope,
|
|
2284
|
-
instruction:
|
|
2285
|
-
excludeConversationMessageId:
|
|
2286
|
-
});
|
|
2641
|
+
instruction: resolvedInstruction,
|
|
2642
|
+
excludeConversationMessageId: currentMessageId
|
|
2643
|
+
}, { skipWarning: true });
|
|
2287
2644
|
sendEvent("context", {
|
|
2288
2645
|
...prepared,
|
|
2289
2646
|
conversation: redactAiConversation({
|
|
@@ -2291,21 +2648,10 @@ export function createRuntime(options) {
|
|
|
2291
2648
|
contextWarningPending: prepared.action === "warn"
|
|
2292
2649
|
}, permissions)
|
|
2293
2650
|
});
|
|
2294
|
-
if (prepared.action === "warn")
|
|
2295
|
-
return;
|
|
2296
|
-
const userMessage = input.currentMessageId
|
|
2297
|
-
? null
|
|
2298
|
-
: store.addAiConversationMessage(conversationId, {
|
|
2299
|
-
role: "user",
|
|
2300
|
-
content: input.instruction,
|
|
2301
|
-
citations
|
|
2302
|
-
});
|
|
2303
|
-
const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
|
|
2304
|
-
if (userMessage)
|
|
2305
|
-
sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
|
|
2306
2651
|
const suggestion = await ai.createStreamingChat({
|
|
2307
2652
|
workId: request.params.workId,
|
|
2308
|
-
instruction:
|
|
2653
|
+
instruction: resolvedInstruction,
|
|
2654
|
+
// 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
|
|
2309
2655
|
scope: input.scope,
|
|
2310
2656
|
signal: controller.signal,
|
|
2311
2657
|
onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
|
|
@@ -2317,6 +2663,13 @@ export function createRuntime(options) {
|
|
|
2317
2663
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
|
2318
2664
|
...(input.parameters ? { parameters: input.parameters } : {})
|
|
2319
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;
|
|
2320
2673
|
sendEvent("complete", {
|
|
2321
2674
|
suggestionId: suggestion.id,
|
|
2322
2675
|
callId: suggestion.callId,
|
|
@@ -2339,6 +2692,18 @@ export function createRuntime(options) {
|
|
|
2339
2692
|
});
|
|
2340
2693
|
}
|
|
2341
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;
|
|
2342
2707
|
if (!controller.signal.aborted) {
|
|
2343
2708
|
logger.error("ai.stream.failed", {
|
|
2344
2709
|
workId: request.params.workId,
|
|
@@ -2348,7 +2713,13 @@ export function createRuntime(options) {
|
|
|
2348
2713
|
}
|
|
2349
2714
|
}
|
|
2350
2715
|
finally {
|
|
2351
|
-
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)
|
|
2352
2723
|
response.end();
|
|
2353
2724
|
}
|
|
2354
2725
|
});
|
|
@@ -2389,7 +2760,7 @@ export function createRuntime(options) {
|
|
|
2389
2760
|
});
|
|
2390
2761
|
app.get("/api/works/:workId/search", async (request, response) => {
|
|
2391
2762
|
const query = parse(z.object({
|
|
2392
|
-
q: z.string().trim().min(1).max(
|
|
2763
|
+
q: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
|
|
2393
2764
|
type: z.enum(HYBRID_SEARCH_TYPES).optional(),
|
|
2394
2765
|
limit: z.coerce.number().int().min(1).max(100).optional()
|
|
2395
2766
|
}).strict(), request.query);
|
|
@@ -2400,8 +2771,13 @@ export function createRuntime(options) {
|
|
|
2400
2771
|
includeAgentHistory: permissions["ai-chat"] !== "none"
|
|
2401
2772
|
}));
|
|
2402
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
|
+
});
|
|
2403
2779
|
app.get("/api/works/:workId/export", async (request, response) => {
|
|
2404
|
-
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");
|
|
2405
2781
|
if (format === "json") {
|
|
2406
2782
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
|
|
2407
2783
|
data(response, store.exportWork(request.params.workId));
|
|
@@ -2427,6 +2803,11 @@ export function createRuntime(options) {
|
|
|
2427
2803
|
response.send(await store.exportDocx(request.params.workId));
|
|
2428
2804
|
return;
|
|
2429
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
|
+
}
|
|
2430
2811
|
response.type("text/plain");
|
|
2431
2812
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
|
|
2432
2813
|
response.send(store.exportText(request.params.workId, format));
|
|
@@ -2513,6 +2894,13 @@ export function createRuntime(options) {
|
|
|
2513
2894
|
}
|
|
2514
2895
|
if (error instanceof multer.MulterError) {
|
|
2515
2896
|
logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
|
|
2897
|
+
if (error.code === "LIMIT_FILE_SIZE") {
|
|
2898
|
+
const sizeError = uploadSizeError(request.path, uploadLimits);
|
|
2899
|
+
if (sizeError) {
|
|
2900
|
+
response.status(413).json({ error: sizeError });
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2516
2904
|
response.status(400).json({ error: { code: "UPLOAD_ERROR", message: error.message } });
|
|
2517
2905
|
return;
|
|
2518
2906
|
}
|
|
@@ -2532,7 +2920,7 @@ export function createRuntime(options) {
|
|
|
2532
2920
|
logger.error("http.request.application_error", logFields);
|
|
2533
2921
|
else
|
|
2534
2922
|
logger.warn("http.request.application_error", logFields);
|
|
2535
|
-
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") {
|
|
2536
2924
|
const retryAfterSeconds = Number(error.details.retryAfterSeconds);
|
|
2537
2925
|
if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
2538
2926
|
response.setHeader("Retry-After", String(retryAfterSeconds));
|
|
@@ -2551,14 +2939,44 @@ export function createRuntime(options) {
|
|
|
2551
2939
|
});
|
|
2552
2940
|
backups.startScheduler();
|
|
2553
2941
|
logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
|
|
2554
|
-
|
|
2942
|
+
let closePromise = null;
|
|
2943
|
+
let stopping = false;
|
|
2944
|
+
let closed = false;
|
|
2945
|
+
const close = () => {
|
|
2946
|
+
if (closed)
|
|
2947
|
+
return Promise.resolve();
|
|
2948
|
+
if (closePromise)
|
|
2949
|
+
return closePromise;
|
|
2950
|
+
if (!stopping) {
|
|
2951
|
+
stopping = true;
|
|
2555
2952
|
logger.info("runtime.closing");
|
|
2556
2953
|
backups.dispose();
|
|
2557
2954
|
ai.dispose();
|
|
2558
|
-
|
|
2559
|
-
if (
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2955
|
+
const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
|
|
2956
|
+
if (cancelledStreamRequests > 0)
|
|
2957
|
+
logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
|
|
2958
|
+
}
|
|
2959
|
+
closePromise = (async () => {
|
|
2960
|
+
try {
|
|
2961
|
+
await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
|
|
2962
|
+
collaborationPresence.close();
|
|
2963
|
+
database.close();
|
|
2964
|
+
if (temporaryAttachmentRoot)
|
|
2965
|
+
rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
|
|
2966
|
+
closed = true;
|
|
2967
|
+
logger.info("runtime.closed");
|
|
2968
|
+
}
|
|
2969
|
+
catch (error) {
|
|
2970
|
+
logger.error("runtime.close_failed", { error: sanitizeError(error) });
|
|
2971
|
+
throw error;
|
|
2972
|
+
}
|
|
2973
|
+
finally {
|
|
2974
|
+
if (!closed)
|
|
2975
|
+
closePromise = null;
|
|
2976
|
+
}
|
|
2977
|
+
})();
|
|
2978
|
+
return closePromise;
|
|
2979
|
+
};
|
|
2980
|
+
return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
|
|
2563
2981
|
}
|
|
2564
2982
|
//# sourceMappingURL=app.js.map
|