@musnows/scriverse 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -2
- package/README.md +3 -2
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +267 -54
- package/dist/ai.js.map +1 -1
- package/dist/app.js +256 -57
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +9 -1
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +4 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +23 -7
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +7 -5
- package/dist/credential-vault.js.map +1 -1
- package/dist/database.js +243 -2
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/domain.js +9 -0
- package/dist/domain.js.map +1 -1
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +765 -111
- package/dist/public/index.html +49 -17
- package/dist/public/markdown.js +1 -2
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +87 -29
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +143 -16
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +56 -1
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +289 -39
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +56 -88
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/app.js
CHANGED
|
@@ -14,14 +14,14 @@ import { AiManager } from "./ai.js";
|
|
|
14
14
|
import { CredentialVault } from "./credential-vault.js";
|
|
15
15
|
import { Database } from "./database.js";
|
|
16
16
|
import { assertSafeDocxArchive } from "./docx-security.js";
|
|
17
|
-
import { TASK_TYPES } from "./domain.js";
|
|
17
|
+
import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
|
|
18
18
|
import { AppError } from "./errors.js";
|
|
19
19
|
import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
|
|
20
20
|
import { applyImportFileHints, parseNovelText } from "./parser.js";
|
|
21
|
-
import { Store, versionedEntityTypes } from "./store.js";
|
|
22
|
-
import { parsePagination } from "./pagination.js";
|
|
21
|
+
import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
|
|
22
|
+
import { paginated, parsePagination } from "./pagination.js";
|
|
23
23
|
import { normalizeUploadFileName } from "./utils.js";
|
|
24
|
-
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware } from "./security.js";
|
|
24
|
+
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
|
|
25
25
|
import { ImageCaptchaService } from "./image-captcha.js";
|
|
26
26
|
import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
|
|
27
27
|
import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
|
|
@@ -29,7 +29,7 @@ import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logg
|
|
|
29
29
|
import { accountReference, logger, sanitizeError } from "./logger.js";
|
|
30
30
|
import { currentRequestActor, runWithRequestActor } from "./request-context.js";
|
|
31
31
|
import { APP_VERSION } from "./version.js";
|
|
32
|
-
import { fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
32
|
+
import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
33
33
|
import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
|
|
34
34
|
import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
|
|
35
35
|
const nonEmpty = z.string().trim().min(1);
|
|
@@ -38,6 +38,7 @@ const optionalStrings = z.array(z.string()).optional();
|
|
|
38
38
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
39
39
|
const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
|
|
40
40
|
const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
|
|
41
|
+
const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
|
|
41
42
|
const maximumImportedTextLength = 20_000_000;
|
|
42
43
|
const maximumKnowledgeSectionsLength = 4_000_000;
|
|
43
44
|
const captchaFields = {
|
|
@@ -50,6 +51,7 @@ const registrationSchema = z.object({
|
|
|
50
51
|
username: usernameSchema,
|
|
51
52
|
password: passwordSchema,
|
|
52
53
|
passwordConfirmation: passwordSchema,
|
|
54
|
+
setupToken: z.string().max(500).optional(),
|
|
53
55
|
...captchaFields
|
|
54
56
|
}).strict().refine((input) => input.password === input.passwordConfirmation, {
|
|
55
57
|
path: ["passwordConfirmation"],
|
|
@@ -144,6 +146,8 @@ const settingSchema = z.object({
|
|
|
144
146
|
});
|
|
145
147
|
const draftSchema = z.object({
|
|
146
148
|
draftType: z.enum(["prose", "setting"]),
|
|
149
|
+
volumeId: identifier.nullable().optional(),
|
|
150
|
+
settingModule: z.enum(DRAFT_SETTING_MODULES).nullable().optional(),
|
|
147
151
|
title: nonEmpty.max(200),
|
|
148
152
|
content: z.string().max(200_000)
|
|
149
153
|
}).strict();
|
|
@@ -375,6 +379,7 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
|
|
|
375
379
|
]);
|
|
376
380
|
const workAiSettingsSchema = z.object({
|
|
377
381
|
systemPrompt: z.string().max(100_000).optional(),
|
|
382
|
+
dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
|
|
378
383
|
autoRunEnabled: z.boolean().optional(),
|
|
379
384
|
autoRunConcurrency: z.number().int().min(1).max(8).optional(),
|
|
380
385
|
autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
|
|
@@ -382,6 +387,8 @@ const workAiSettingsSchema = z.object({
|
|
|
382
387
|
autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
|
|
383
388
|
bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
|
|
384
389
|
contextCompactThreshold: z.number().int().min(50).max(90).optional(),
|
|
390
|
+
agentToolCallLimit: z.number().int().min(5).max(48).optional(),
|
|
391
|
+
agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
|
|
385
392
|
agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"])).max(6).optional(),
|
|
386
393
|
titleGenerationModelId: z.string().trim().max(200).optional()
|
|
387
394
|
}).strict();
|
|
@@ -596,6 +603,114 @@ function redactTaskCharacterNames(record, permissions) {
|
|
|
596
603
|
}
|
|
597
604
|
return result;
|
|
598
605
|
}
|
|
606
|
+
function redactAiCallContext(record, permissions) {
|
|
607
|
+
const result = { ...record };
|
|
608
|
+
const scope = recordValue(result.contextScope);
|
|
609
|
+
if (!scope)
|
|
610
|
+
return result;
|
|
611
|
+
const redactedScope = { ...scope };
|
|
612
|
+
let restricted = false;
|
|
613
|
+
if (permissions.prose === "none") {
|
|
614
|
+
for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
|
|
615
|
+
if (field in redactedScope) {
|
|
616
|
+
delete redactedScope[field];
|
|
617
|
+
restricted = true;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (permissions.characters === "none" && "characterIds" in redactedScope) {
|
|
622
|
+
delete redactedScope.characterIds;
|
|
623
|
+
restricted = true;
|
|
624
|
+
}
|
|
625
|
+
if (permissions.settings === "none" && "settingIds" in redactedScope) {
|
|
626
|
+
delete redactedScope.settingIds;
|
|
627
|
+
restricted = true;
|
|
628
|
+
}
|
|
629
|
+
result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
const proseRestrictedPlaceholder = "(正文读取权限受限)";
|
|
633
|
+
function redactContinuationGuard(record, permissions) {
|
|
634
|
+
if (permissions.prose !== "none")
|
|
635
|
+
return record;
|
|
636
|
+
return {
|
|
637
|
+
...record,
|
|
638
|
+
issues: [],
|
|
639
|
+
contextRefs: {},
|
|
640
|
+
failure: null,
|
|
641
|
+
restricted: true
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/** 无正文读取权限时移除建议中的原文、指令和检查证据,避免通过 AI 接口绕过 prose=none。 */
|
|
645
|
+
function redactSuggestion(record, permissions) {
|
|
646
|
+
if (permissions.prose !== "none")
|
|
647
|
+
return record;
|
|
648
|
+
const guard = recordValue(record.guard);
|
|
649
|
+
return {
|
|
650
|
+
...record,
|
|
651
|
+
instruction: proseRestrictedPlaceholder,
|
|
652
|
+
sourceText: "",
|
|
653
|
+
...(guard ? { guard: redactContinuationGuard(guard, permissions) } : {}),
|
|
654
|
+
restricted: true
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function redactAiConversationMessage(item, permissions) {
|
|
658
|
+
if (permissions.prose !== "none")
|
|
659
|
+
return item;
|
|
660
|
+
const message = recordValue(item);
|
|
661
|
+
if (!message)
|
|
662
|
+
return item;
|
|
663
|
+
return {
|
|
664
|
+
...message,
|
|
665
|
+
content: proseRestrictedPlaceholder,
|
|
666
|
+
citations: [],
|
|
667
|
+
metadata: { restricted: true },
|
|
668
|
+
restricted: true
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** 无正文读取权限时隐藏对话预览与消息正文,避免历史对话泄露章节原文。 */
|
|
672
|
+
function redactAiConversation(record, permissions) {
|
|
673
|
+
if (permissions.prose !== "none")
|
|
674
|
+
return record;
|
|
675
|
+
const result = {
|
|
676
|
+
...record,
|
|
677
|
+
title: proseRestrictedPlaceholder
|
|
678
|
+
};
|
|
679
|
+
if (typeof result.preview === "string" && result.preview.length > 0) {
|
|
680
|
+
result.preview = proseRestrictedPlaceholder;
|
|
681
|
+
}
|
|
682
|
+
if (Array.isArray(result.messages)) {
|
|
683
|
+
result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
|
|
684
|
+
}
|
|
685
|
+
const messagesPage = recordValue(result.messagesPage);
|
|
686
|
+
if (messagesPage && Array.isArray(messagesPage.items)) {
|
|
687
|
+
result.messagesPage = {
|
|
688
|
+
...messagesPage,
|
|
689
|
+
items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return { ...result, restricted: true };
|
|
693
|
+
}
|
|
694
|
+
/** SSE 错误事件只暴露 AppError 的公开信息,避免透传内部异常 message。 */
|
|
695
|
+
export function publicAiStreamError(error) {
|
|
696
|
+
if (error instanceof AppError) {
|
|
697
|
+
const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
|
|
698
|
+
? error.details
|
|
699
|
+
: null;
|
|
700
|
+
return {
|
|
701
|
+
code: error.code,
|
|
702
|
+
message: error.message,
|
|
703
|
+
status: error.status,
|
|
704
|
+
...(error.status < 500 && typeof details?.failure === "string" ? { failure: details.failure } : {}),
|
|
705
|
+
...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
|
|
706
|
+
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
707
|
+
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
708
|
+
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
709
|
+
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
|
|
713
|
+
}
|
|
599
714
|
function redactMergeRecords(value, mapper) {
|
|
600
715
|
const record = recordValue(value);
|
|
601
716
|
if (!record)
|
|
@@ -622,6 +737,7 @@ export function createRuntime(options) {
|
|
|
622
737
|
sameOriginEnforced: options.security?.enforceSameOrigin ?? true
|
|
623
738
|
});
|
|
624
739
|
const database = new Database(options.databasePath);
|
|
740
|
+
const bootId = randomUUID();
|
|
625
741
|
const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
|
|
626
742
|
? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
|
|
627
743
|
: null;
|
|
@@ -642,6 +758,32 @@ export function createRuntime(options) {
|
|
|
642
758
|
? auth.listUsers().find((user) => user.status === "active") ?? null
|
|
643
759
|
: null;
|
|
644
760
|
const store = new Store(database);
|
|
761
|
+
let attachmentCleanupChain = Promise.resolve();
|
|
762
|
+
const cleanupAttachments = () => {
|
|
763
|
+
const cleanup = attachmentCleanupChain.then(async () => {
|
|
764
|
+
store.queueUnreferencedAttachments();
|
|
765
|
+
for (const queued of store.listAttachmentCleanupQueue()) {
|
|
766
|
+
if (!store.attachmentCleanupStillRequired(queued.storageKey)) {
|
|
767
|
+
store.completeAttachmentCleanup(queued.storageKey);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
await attachmentStorage.remove(queued.storageKey);
|
|
772
|
+
store.completeAttachmentCleanup(queued.storageKey);
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
store.failAttachmentCleanup(queued.storageKey, error instanceof Error ? error.message : "Attachment cleanup failed");
|
|
776
|
+
logger.warn("attachment.cleanup.failed", {
|
|
777
|
+
storageKey: queued.storageKey,
|
|
778
|
+
attempts: queued.attempts + 1,
|
|
779
|
+
error: sanitizeError(error)
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
attachmentCleanupChain = cleanup.catch(() => undefined);
|
|
785
|
+
return cleanup;
|
|
786
|
+
};
|
|
645
787
|
const requestPermissions = (request, workId) => {
|
|
646
788
|
if (!request.authUser)
|
|
647
789
|
return fullWorkModulePermissions();
|
|
@@ -666,6 +808,7 @@ export function createRuntime(options) {
|
|
|
666
808
|
}, false, actor?.allowAdminAccess ?? false);
|
|
667
809
|
});
|
|
668
810
|
const app = express();
|
|
811
|
+
enforceCaseInsensitiveRouting(app);
|
|
669
812
|
const upload = multer({
|
|
670
813
|
storage: multer.memoryStorage(),
|
|
671
814
|
limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
|
|
@@ -686,13 +829,18 @@ export function createRuntime(options) {
|
|
|
686
829
|
limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
|
|
687
830
|
});
|
|
688
831
|
app.disable("x-powered-by");
|
|
689
|
-
|
|
690
|
-
|
|
832
|
+
const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
|
|
833
|
+
if (options.security?.trustProxy === true) {
|
|
834
|
+
logger.warn("security.trust_proxy.coerced", { from: true, to: 1 });
|
|
835
|
+
}
|
|
836
|
+
if (trustProxy !== undefined)
|
|
837
|
+
app.set("trust proxy", trustProxy);
|
|
691
838
|
app.use(createRequestLoggingMiddleware());
|
|
692
839
|
app.use(createSecurityHeadersMiddleware());
|
|
693
840
|
app.get("/api/health", (_request, response) => {
|
|
694
841
|
data(response, {
|
|
695
842
|
status: "ok",
|
|
843
|
+
bootId,
|
|
696
844
|
version: APP_VERSION,
|
|
697
845
|
protocol: "openai-chat-completions",
|
|
698
846
|
protocols: ["openai-chat-completions", "anthropic-messages"],
|
|
@@ -702,6 +850,7 @@ export function createRuntime(options) {
|
|
|
702
850
|
if (options.security?.auth)
|
|
703
851
|
app.use(createBasicAuthMiddleware(options.security.auth));
|
|
704
852
|
app.use(createAuthenticationRateLimitMiddleware());
|
|
853
|
+
app.use(createCaptchaRateLimitMiddleware());
|
|
705
854
|
app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
|
|
706
855
|
if (options.security?.enforceSameOrigin ?? true)
|
|
707
856
|
app.use(createSameOriginMiddleware());
|
|
@@ -709,14 +858,16 @@ export function createRuntime(options) {
|
|
|
709
858
|
app.get("/api/auth/session", (request, response) => {
|
|
710
859
|
const session = auth.authenticate(request);
|
|
711
860
|
const registrationOpen = options.security?.allowRegistration === true;
|
|
861
|
+
const setupRequired = !auth.hasUsers();
|
|
862
|
+
const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
|
|
712
863
|
const developmentUser = getDevelopmentUser();
|
|
713
864
|
if (!session && developmentUser) {
|
|
714
|
-
data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, registrationOpen });
|
|
865
|
+
data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
715
866
|
return;
|
|
716
867
|
}
|
|
717
868
|
data(response, session
|
|
718
|
-
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, registrationOpen }
|
|
719
|
-
: { authenticated: false, user: null, csrfToken: null, setupRequired
|
|
869
|
+
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
|
|
870
|
+
: { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
|
|
720
871
|
});
|
|
721
872
|
app.get("/api/auth/captcha", (_request, response) => {
|
|
722
873
|
data(response, captcha.create());
|
|
@@ -727,6 +878,9 @@ export function createRuntime(options) {
|
|
|
727
878
|
}
|
|
728
879
|
const input = parse(registrationSchema, request.body);
|
|
729
880
|
captcha.consume(input.captchaId, input.captchaAnswer);
|
|
881
|
+
if (!auth.hasUsers() && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
|
|
882
|
+
throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
|
|
883
|
+
}
|
|
730
884
|
const result = auth.register({ username: input.username, password: input.password });
|
|
731
885
|
setSessionCookie(response, result.token, request.secure);
|
|
732
886
|
runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
|
|
@@ -746,6 +900,8 @@ export function createRuntime(options) {
|
|
|
746
900
|
disabled: options.disableUserAuth === true,
|
|
747
901
|
resolveBypassUser: getDevelopmentUser
|
|
748
902
|
}));
|
|
903
|
+
app.use(createUploadRateLimitMiddleware());
|
|
904
|
+
app.use(createExpensiveApiRateLimitMiddleware());
|
|
749
905
|
app.use(createCliApiScopeMiddleware(options.disableUserAuth));
|
|
750
906
|
app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
|
|
751
907
|
app.get("/api/cli/session", (request, response) => {
|
|
@@ -854,6 +1010,8 @@ export function createRuntime(options) {
|
|
|
854
1010
|
app.patch("/api/users/:userId", (request, response) => {
|
|
855
1011
|
if (!request.authUser)
|
|
856
1012
|
throw new AppError(401, "AUTH_REQUIRED", "请先登录");
|
|
1013
|
+
if (request.authUser.role !== "admin")
|
|
1014
|
+
throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
|
|
857
1015
|
const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
|
|
858
1016
|
store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
|
|
859
1017
|
data(response, updated);
|
|
@@ -945,8 +1103,8 @@ export function createRuntime(options) {
|
|
|
945
1103
|
});
|
|
946
1104
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
947
1105
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
948
|
-
|
|
949
|
-
await
|
|
1106
|
+
store.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
1107
|
+
await cleanupAttachments();
|
|
950
1108
|
noContent(response);
|
|
951
1109
|
});
|
|
952
1110
|
app.get("/api/works/:workId/cover", (request, response) => {
|
|
@@ -961,14 +1119,16 @@ export function createRuntime(options) {
|
|
|
961
1119
|
if (!request.file)
|
|
962
1120
|
throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
|
|
963
1121
|
const bytes = request.file.buffer;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1122
|
+
try {
|
|
1123
|
+
const metadata = readRasterImageMetadata(bytes);
|
|
1124
|
+
const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
|
|
1125
|
+
data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
|
|
1126
|
+
}
|
|
1127
|
+
catch (error) {
|
|
1128
|
+
if (error instanceof InvalidRasterImageError)
|
|
1129
|
+
throw new AppError(415, "INVALID_COVER", error.message);
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
972
1132
|
});
|
|
973
1133
|
app.delete("/api/works/:workId/cover", (request, response) => {
|
|
974
1134
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
@@ -1301,11 +1461,16 @@ export function createRuntime(options) {
|
|
|
1301
1461
|
});
|
|
1302
1462
|
app.get("/api/works/:workId/attachments", (request, response) => {
|
|
1303
1463
|
const pagination = parsePagination(request.query);
|
|
1304
|
-
|
|
1464
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1465
|
+
const readable = store.listAttachments(request.params.workId).filter((attachment) => (store.attachmentModules(String(attachment.id)).some((module) => canReadWorkModule(permissions, module))));
|
|
1466
|
+
data(response, pagination
|
|
1467
|
+
? paginated(readable.slice(pagination.offset, pagination.offset + pagination.limit + 1), pagination, readable.length)
|
|
1468
|
+
: readable);
|
|
1305
1469
|
});
|
|
1306
1470
|
app.post("/api/works/:workId/attachments", attachmentUpload.single("file"), async (request, response) => {
|
|
1307
1471
|
if (!request.file)
|
|
1308
1472
|
throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
|
|
1473
|
+
const accessModule = parse(attachmentPermissionModuleSchema, request.query.module ?? "settings");
|
|
1309
1474
|
let storageKey = null;
|
|
1310
1475
|
try {
|
|
1311
1476
|
const stored = await attachmentStorage.ingest(request.file.path);
|
|
@@ -1313,7 +1478,7 @@ export function createRuntime(options) {
|
|
|
1313
1478
|
const result = store.createAttachment(String(request.params.workId), {
|
|
1314
1479
|
originalName: normalizeUploadFileName(request.file.originalname),
|
|
1315
1480
|
...stored
|
|
1316
|
-
});
|
|
1481
|
+
}, accessModule);
|
|
1317
1482
|
data(response, { ...result.attachment, deduplicated: !result.created }, result.created ? 201 : 200);
|
|
1318
1483
|
}
|
|
1319
1484
|
catch (error) {
|
|
@@ -1330,6 +1495,10 @@ export function createRuntime(options) {
|
|
|
1330
1495
|
});
|
|
1331
1496
|
app.get("/api/attachments/:attachmentId/content", async (request, response) => {
|
|
1332
1497
|
const attachment = store.getAttachment(request.params.attachmentId);
|
|
1498
|
+
const permissions = requestPermissions(request, String(attachment.workId));
|
|
1499
|
+
if (!store.attachmentModules(request.params.attachmentId).some((module) => canReadWorkModule(permissions, module))) {
|
|
1500
|
+
throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
|
|
1501
|
+
}
|
|
1333
1502
|
const content = await attachmentStorage.read(String(attachment.storageKey));
|
|
1334
1503
|
response.setHeader("Content-Type", String(attachment.storedMimeType));
|
|
1335
1504
|
response.setHeader("Content-Length", String(attachment.storedByteLength));
|
|
@@ -1339,9 +1508,13 @@ export function createRuntime(options) {
|
|
|
1339
1508
|
response.send(content);
|
|
1340
1509
|
});
|
|
1341
1510
|
app.delete("/api/attachments/:attachmentId", async (request, response) => {
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1511
|
+
const attachment = store.getAttachment(request.params.attachmentId);
|
|
1512
|
+
const permissions = requestPermissions(request, String(attachment.workId));
|
|
1513
|
+
if (!store.attachmentModules(request.params.attachmentId).some((module) => canWriteWorkModule(permissions, module))) {
|
|
1514
|
+
throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有编辑该附件所属资料模块的权限");
|
|
1515
|
+
}
|
|
1516
|
+
store.deleteAttachment(request.params.attachmentId);
|
|
1517
|
+
await cleanupAttachments();
|
|
1345
1518
|
noContent(response);
|
|
1346
1519
|
});
|
|
1347
1520
|
app.get("/api/works/:workId/races", (request, response) => {
|
|
@@ -1703,7 +1876,8 @@ export function createRuntime(options) {
|
|
|
1703
1876
|
page: request.query.page ?? "1",
|
|
1704
1877
|
limit: request.query.limit ?? "20"
|
|
1705
1878
|
}) ?? { page: 1, limit: 20, offset: 0 };
|
|
1706
|
-
|
|
1879
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1880
|
+
data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
|
|
1707
1881
|
});
|
|
1708
1882
|
app.post("/api/works/:workId/ai-conversations", (request, response) => {
|
|
1709
1883
|
const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
|
|
@@ -1711,11 +1885,17 @@ export function createRuntime(options) {
|
|
|
1711
1885
|
});
|
|
1712
1886
|
app.get("/api/ai-conversations/:conversationId", (request, response) => {
|
|
1713
1887
|
const pagination = parsePagination(request.query);
|
|
1714
|
-
|
|
1888
|
+
const conversation = pagination
|
|
1889
|
+
? store.getAiConversationPage(request.params.conversationId, pagination)
|
|
1890
|
+
: store.getAiConversation(request.params.conversationId);
|
|
1891
|
+
const permissions = requestPermissions(request, String(conversation.workId));
|
|
1892
|
+
data(response, redactAiConversation(conversation, permissions));
|
|
1715
1893
|
});
|
|
1716
1894
|
app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
|
|
1717
1895
|
const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
|
|
1718
|
-
|
|
1896
|
+
const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
|
|
1897
|
+
const permissions = requestPermissions(request, String(forked.workId));
|
|
1898
|
+
data(response, redactAiConversation(forked, permissions), 201);
|
|
1719
1899
|
});
|
|
1720
1900
|
app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
|
|
1721
1901
|
const input = parse(z.object({
|
|
@@ -1732,7 +1912,10 @@ export function createRuntime(options) {
|
|
|
1732
1912
|
processSteps: z.array(aiProcessStepSchema).max(50).optional()
|
|
1733
1913
|
}).optional()
|
|
1734
1914
|
}), request.body);
|
|
1735
|
-
|
|
1915
|
+
const message = store.addAiConversationMessage(request.params.conversationId, input);
|
|
1916
|
+
const conversation = store.getAiConversationSummary(request.params.conversationId);
|
|
1917
|
+
const permissions = requestPermissions(request, String(conversation.workId));
|
|
1918
|
+
data(response, redactAiConversationMessage(message, permissions), 201);
|
|
1736
1919
|
});
|
|
1737
1920
|
app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
|
|
1738
1921
|
const input = parse(z.object({
|
|
@@ -1814,7 +1997,10 @@ export function createRuntime(options) {
|
|
|
1814
1997
|
app.get("/api/works/:workId/suggestions", (request, response) => {
|
|
1815
1998
|
const status = typeof request.query.status === "string" ? request.query.status : undefined;
|
|
1816
1999
|
const pagination = parsePagination(request.query);
|
|
1817
|
-
|
|
2000
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
2001
|
+
data(response, pagination
|
|
2002
|
+
? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
|
|
2003
|
+
: ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
|
|
1818
2004
|
});
|
|
1819
2005
|
app.post("/api/works/:workId/suggestions", async (request, response) => {
|
|
1820
2006
|
const input = parse(z.object({
|
|
@@ -1830,14 +2016,14 @@ export function createRuntime(options) {
|
|
|
1830
2016
|
if (store.getChapter(citation.chapterId).workId !== request.params.workId)
|
|
1831
2017
|
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
1832
2018
|
}
|
|
1833
|
-
data(response, await ai.createSuggestion({
|
|
2019
|
+
data(response, redactSuggestion(await ai.createSuggestion({
|
|
1834
2020
|
workId: request.params.workId,
|
|
1835
2021
|
taskType: input.taskType,
|
|
1836
2022
|
instruction: instructionWithCitations(input.instruction, citations),
|
|
1837
2023
|
scope: input.scope,
|
|
1838
2024
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
|
1839
2025
|
...(input.parameters ? { parameters: input.parameters } : {})
|
|
1840
|
-
}), 201);
|
|
2026
|
+
}), requestPermissions(request, request.params.workId)), 201);
|
|
1841
2027
|
});
|
|
1842
2028
|
app.post("/api/works/:workId/chat/stream", async (request, response) => {
|
|
1843
2029
|
const input = parse(z.object({
|
|
@@ -1878,6 +2064,7 @@ export function createRuntime(options) {
|
|
|
1878
2064
|
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
1879
2065
|
}
|
|
1880
2066
|
const conversationId = String(conversation.id);
|
|
2067
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1881
2068
|
const prepared = await ai.prepareConversationContext({
|
|
1882
2069
|
conversationId,
|
|
1883
2070
|
workId: request.params.workId,
|
|
@@ -1888,10 +2075,10 @@ export function createRuntime(options) {
|
|
|
1888
2075
|
});
|
|
1889
2076
|
sendEvent("context", {
|
|
1890
2077
|
...prepared,
|
|
1891
|
-
conversation: {
|
|
2078
|
+
conversation: redactAiConversation({
|
|
1892
2079
|
...store.getAiConversationSummary(conversationId),
|
|
1893
2080
|
contextWarningPending: prepared.action === "warn"
|
|
1894
|
-
}
|
|
2081
|
+
}, permissions)
|
|
1895
2082
|
});
|
|
1896
2083
|
if (prepared.action === "warn")
|
|
1897
2084
|
return;
|
|
@@ -1904,7 +2091,7 @@ export function createRuntime(options) {
|
|
|
1904
2091
|
});
|
|
1905
2092
|
const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
|
|
1906
2093
|
if (userMessage)
|
|
1907
|
-
sendEvent("user_message", { message: userMessage });
|
|
2094
|
+
sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
|
|
1908
2095
|
const suggestion = await ai.createStreamingChat({
|
|
1909
2096
|
workId: request.params.workId,
|
|
1910
2097
|
instruction: instructionWithCitations(input.instruction, citations),
|
|
@@ -1942,20 +2129,11 @@ export function createRuntime(options) {
|
|
|
1942
2129
|
}
|
|
1943
2130
|
catch (error) {
|
|
1944
2131
|
if (!controller.signal.aborted) {
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
:
|
|
1948
|
-
sendEvent("error", {
|
|
1949
|
-
code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
|
|
1950
|
-
message: error instanceof Error ? error.message : "AI 流式调用失败",
|
|
1951
|
-
...(error instanceof AppError ? { status: error.status } : {}),
|
|
1952
|
-
...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
|
|
1953
|
-
...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
|
|
1954
|
-
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
1955
|
-
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
1956
|
-
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
1957
|
-
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
|
|
2132
|
+
logger.error("ai.stream.failed", {
|
|
2133
|
+
workId: request.params.workId,
|
|
2134
|
+
error: sanitizeError(error)
|
|
1958
2135
|
});
|
|
2136
|
+
sendEvent("error", publicAiStreamError(error));
|
|
1959
2137
|
}
|
|
1960
2138
|
}
|
|
1961
2139
|
finally {
|
|
@@ -1963,25 +2141,40 @@ export function createRuntime(options) {
|
|
|
1963
2141
|
response.end();
|
|
1964
2142
|
}
|
|
1965
2143
|
});
|
|
1966
|
-
app.get("/api/suggestions/:suggestionId", (request, response) =>
|
|
2144
|
+
app.get("/api/suggestions/:suggestionId", (request, response) => {
|
|
2145
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2146
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2147
|
+
data(response, redactSuggestion(suggestion, permissions));
|
|
2148
|
+
});
|
|
1967
2149
|
app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
|
|
1968
2150
|
const pagination = parsePagination(request.query);
|
|
1969
|
-
|
|
2151
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2152
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2153
|
+
data(response, mapRecords(pagination
|
|
1970
2154
|
? store.listContinuationGuardsPage(request.params.suggestionId, pagination)
|
|
1971
|
-
: store.listContinuationGuards(request.params.suggestionId));
|
|
2155
|
+
: store.listContinuationGuards(request.params.suggestionId), (guard) => redactContinuationGuard(guard, permissions)));
|
|
1972
2156
|
});
|
|
1973
2157
|
app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
|
|
1974
2158
|
const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
|
|
1975
|
-
|
|
2159
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2160
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2161
|
+
data(response, redactContinuationGuard(await ai.runSuggestionGuard(request.params.suggestionId, input.content), permissions), 201);
|
|
1976
2162
|
});
|
|
1977
2163
|
app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
|
|
1978
2164
|
const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
|
|
1979
2165
|
data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
|
|
1980
2166
|
});
|
|
1981
|
-
app.post("/api/suggestions/:suggestionId/reject", (request, response) =>
|
|
2167
|
+
app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
|
|
2168
|
+
const suggestion = ai.rejectSuggestion(request.params.suggestionId);
|
|
2169
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2170
|
+
data(response, redactSuggestion(suggestion, permissions));
|
|
2171
|
+
});
|
|
1982
2172
|
app.get("/api/works/:workId/ai-calls", (request, response) => {
|
|
1983
2173
|
const pagination = parsePagination(request.query);
|
|
1984
|
-
|
|
2174
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
2175
|
+
data(response, pagination
|
|
2176
|
+
? mapRecords(ai.listCallsPage(request.params.workId, pagination), (call) => redactAiCallContext(call, permissions))
|
|
2177
|
+
: ai.listCalls(request.params.workId).map((call) => redactAiCallContext(call, permissions)));
|
|
1985
2178
|
});
|
|
1986
2179
|
app.get("/api/works/:workId/search", async (request, response) => {
|
|
1987
2180
|
const query = parse(z.object({
|
|
@@ -1992,7 +2185,7 @@ export function createRuntime(options) {
|
|
|
1992
2185
|
data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
|
|
1993
2186
|
});
|
|
1994
2187
|
app.get("/api/works/:workId/export", async (request, response) => {
|
|
1995
|
-
const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
|
|
2188
|
+
const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
|
|
1996
2189
|
if (format === "json") {
|
|
1997
2190
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
|
|
1998
2191
|
data(response, store.exportWork(request.params.workId));
|
|
@@ -2012,6 +2205,12 @@ export function createRuntime(options) {
|
|
|
2012
2205
|
}), response);
|
|
2013
2206
|
return;
|
|
2014
2207
|
}
|
|
2208
|
+
if (format === "docx") {
|
|
2209
|
+
response.type("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
|
2210
|
+
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.docx`);
|
|
2211
|
+
response.send(await store.exportDocx(request.params.workId));
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2015
2214
|
response.type("text/plain");
|
|
2016
2215
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
|
|
2017
2216
|
response.send(store.exportText(request.params.workId, format));
|
|
@@ -2071,7 +2270,7 @@ export function createRuntime(options) {
|
|
|
2071
2270
|
setHeaders: setStaticCacheControl
|
|
2072
2271
|
}));
|
|
2073
2272
|
app.get("/{*path}", (request, response, next) => {
|
|
2074
|
-
if (request.path.startsWith("/api/"))
|
|
2273
|
+
if (normalizeApiPath(request.path).startsWith("/api/"))
|
|
2075
2274
|
return next();
|
|
2076
2275
|
sendIndexHtml(request, response);
|
|
2077
2276
|
});
|
|
@@ -2135,7 +2334,7 @@ export function createRuntime(options) {
|
|
|
2135
2334
|
response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
|
|
2136
2335
|
});
|
|
2137
2336
|
logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
|
|
2138
|
-
return { app, database, store, ai, auth, attachmentStorage, close: () => {
|
|
2337
|
+
return { app, database, store, ai, auth, attachmentStorage, cleanupAttachments, close: () => {
|
|
2139
2338
|
logger.info("runtime.closing");
|
|
2140
2339
|
ai.dispose();
|
|
2141
2340
|
database.close();
|