@musnows/scriverse 0.6.2 → 0.6.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/dist/ai-protocol.js +22 -9
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +913 -195
- package/dist/ai.js.map +1 -1
- package/dist/app.js +245 -46
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +23 -13
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +251 -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/google-vertex-auth.js +155 -0
- package/dist/google-vertex-auth.js.map +1 -0
- 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-mentions.js +10 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +1004 -131
- package/dist/public/display-labels.js +2 -1
- package/dist/public/index.html +75 -16
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +155 -19
- 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 +101 -9
- package/dist/security.js.map +1 -1
- package/dist/store.js +290 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +62 -24
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +23 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/app.js
CHANGED
|
@@ -9,6 +9,7 @@ import { rm } from "node:fs/promises";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { pipeline } from "node:stream/promises";
|
|
11
11
|
import { z, ZodError } from "zod";
|
|
12
|
+
import { AI_PROVIDER_PROTOCOLS } from "./ai-protocol.js";
|
|
12
13
|
import { AttachmentStorage } from "./attachment-storage.js";
|
|
13
14
|
import { AiManager } from "./ai.js";
|
|
14
15
|
import { CredentialVault } from "./credential-vault.js";
|
|
@@ -16,12 +17,13 @@ import { Database } from "./database.js";
|
|
|
16
17
|
import { assertSafeDocxArchive } from "./docx-security.js";
|
|
17
18
|
import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
|
|
18
19
|
import { AppError } from "./errors.js";
|
|
20
|
+
import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
|
|
19
21
|
import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
|
|
20
22
|
import { applyImportFileHints, parseNovelText } from "./parser.js";
|
|
21
|
-
import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
|
|
23
|
+
import { aiConversationTaskTypes, attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
|
|
22
24
|
import { paginated, parsePagination } from "./pagination.js";
|
|
23
25
|
import { normalizeUploadFileName } from "./utils.js";
|
|
24
|
-
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, verifySetupToken } from "./security.js";
|
|
26
|
+
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
|
|
25
27
|
import { ImageCaptchaService } from "./image-captcha.js";
|
|
26
28
|
import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
|
|
27
29
|
import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
|
|
@@ -37,6 +39,7 @@ const identifier = z.string().trim().min(1).max(200);
|
|
|
37
39
|
const optionalStrings = z.array(z.string()).optional();
|
|
38
40
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
39
41
|
const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
|
|
42
|
+
const aiConversationTaskTypeSchema = z.enum(aiConversationTaskTypes);
|
|
40
43
|
const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
|
|
41
44
|
const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
|
|
42
45
|
const maximumImportedTextLength = 20_000_000;
|
|
@@ -289,16 +292,54 @@ const reviewSchema = z.object({
|
|
|
289
292
|
status: z.enum(["pending", "ignored", "fixing", "fixed", "exception"]).optional(),
|
|
290
293
|
resolutionNote: z.string().max(20_000).optional()
|
|
291
294
|
});
|
|
292
|
-
const
|
|
295
|
+
const providerBaseSchema = z.object({
|
|
293
296
|
name: nonEmpty.max(200),
|
|
294
297
|
baseUrl: z.string().url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), "接口地址必须使用 HTTP 或 HTTPS"),
|
|
295
|
-
apiKey:
|
|
296
|
-
protocol: z.enum(
|
|
298
|
+
apiKey: z.string().trim().min(1).max(50_000),
|
|
299
|
+
protocol: z.enum(AI_PROVIDER_PROTOCOLS).optional(),
|
|
297
300
|
status: z.enum(["enabled", "disabled"]).optional(),
|
|
298
301
|
note: z.string().max(10_000).optional(),
|
|
299
302
|
concurrencyLimit: z.number().int().min(1).max(100).optional(),
|
|
300
303
|
rpmLimit: z.number().int().min(1).max(10_000).optional()
|
|
301
304
|
});
|
|
305
|
+
function refineProviderApiKey(value, ctx) {
|
|
306
|
+
if (value.protocol === "google-vertex" && value.baseUrl && !isOfficialGoogleVertexBaseUrl(value.baseUrl)) {
|
|
307
|
+
ctx.addIssue({
|
|
308
|
+
code: z.ZodIssueCode.custom,
|
|
309
|
+
path: ["baseUrl"],
|
|
310
|
+
message: "Google Vertex 接口地址必须使用官方 aiplatform.googleapis.com 或 *-aiplatform.googleapis.com 域名"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (!value.apiKey)
|
|
314
|
+
return;
|
|
315
|
+
const protocol = value.protocol ?? "openai-chat-completions";
|
|
316
|
+
if (protocol === "google-vertex") {
|
|
317
|
+
try {
|
|
318
|
+
parseGoogleServiceAccount(value.apiKey);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
ctx.addIssue({
|
|
322
|
+
code: z.ZodIssueCode.custom,
|
|
323
|
+
path: ["apiKey"],
|
|
324
|
+
message: error instanceof AppError ? error.message : "服务账号 JSON 无效"
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (value.apiKey.length > 10_000) {
|
|
330
|
+
ctx.addIssue({
|
|
331
|
+
code: z.ZodIssueCode.custom,
|
|
332
|
+
path: ["apiKey"],
|
|
333
|
+
message: "API 密钥过长"
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const providerSchema = providerBaseSchema.superRefine((value, ctx) => {
|
|
338
|
+
refineProviderApiKey(value, ctx);
|
|
339
|
+
});
|
|
340
|
+
const providerUpdateSchema = providerBaseSchema.partial().superRefine((value, ctx) => {
|
|
341
|
+
refineProviderApiKey(value, ctx);
|
|
342
|
+
});
|
|
302
343
|
const modelSchema = z.object({
|
|
303
344
|
displayName: nonEmpty.max(200),
|
|
304
345
|
modelId: nonEmpty.max(300),
|
|
@@ -379,6 +420,7 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
|
|
|
379
420
|
]);
|
|
380
421
|
const workAiSettingsSchema = z.object({
|
|
381
422
|
systemPrompt: z.string().max(100_000).optional(),
|
|
423
|
+
dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
|
|
382
424
|
autoRunEnabled: z.boolean().optional(),
|
|
383
425
|
autoRunConcurrency: z.number().int().min(1).max(8).optional(),
|
|
384
426
|
autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
|
|
@@ -386,18 +428,24 @@ const workAiSettingsSchema = z.object({
|
|
|
386
428
|
autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
|
|
387
429
|
bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
|
|
388
430
|
contextCompactThreshold: z.number().int().min(50).max(90).optional(),
|
|
431
|
+
agentToolCallLimit: z.number().int().min(5).max(48).optional(),
|
|
432
|
+
agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
|
|
389
433
|
agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"])).max(6).optional(),
|
|
390
434
|
titleGenerationModelId: z.string().trim().max(200).optional()
|
|
391
435
|
}).strict();
|
|
392
436
|
const contextSchema = z.object({
|
|
393
|
-
type: z.enum(["none", "selection", "chapter", "volume", "book", "entities"]),
|
|
437
|
+
type: z.enum(["none", "selection", "chapter", "volume", "book", "settings-catalog", "entities"]),
|
|
394
438
|
chapterId: identifier.optional(),
|
|
395
439
|
volumeId: identifier.optional(),
|
|
396
440
|
selection: z.string().max(200_000).optional(),
|
|
397
441
|
chapterIds: z.array(identifier).max(20).optional(),
|
|
398
442
|
characterIds: optionalStrings,
|
|
443
|
+
mentionCharacterIds: optionalStrings,
|
|
399
444
|
settingIds: optionalStrings,
|
|
400
|
-
|
|
445
|
+
raceIds: optionalStrings,
|
|
446
|
+
organizationIds: optionalStrings,
|
|
447
|
+
includeBookSummary: z.boolean().optional(),
|
|
448
|
+
includeSettingInfo: z.boolean().optional()
|
|
401
449
|
});
|
|
402
450
|
const analysisTaskTypeSchema = z.enum(["structure", "chapter-analysis", "character-extraction", "character-summary", "character-identity-audit", "timeline-analysis", "worldview-analysis", "setting-extraction", "consistency-check", "report-update", "book-analysis"]);
|
|
403
451
|
const relationshipSourceRefSchema = z.object({
|
|
@@ -619,6 +667,18 @@ function redactAiCallContext(record, permissions) {
|
|
|
619
667
|
delete redactedScope.characterIds;
|
|
620
668
|
restricted = true;
|
|
621
669
|
}
|
|
670
|
+
if (permissions.characters === "none" && "mentionCharacterIds" in redactedScope) {
|
|
671
|
+
delete redactedScope.mentionCharacterIds;
|
|
672
|
+
restricted = true;
|
|
673
|
+
}
|
|
674
|
+
if (permissions.races === "none" && "raceIds" in redactedScope) {
|
|
675
|
+
delete redactedScope.raceIds;
|
|
676
|
+
restricted = true;
|
|
677
|
+
}
|
|
678
|
+
if (permissions.organizations === "none" && "organizationIds" in redactedScope) {
|
|
679
|
+
delete redactedScope.organizationIds;
|
|
680
|
+
restricted = true;
|
|
681
|
+
}
|
|
622
682
|
if (permissions.settings === "none" && "settingIds" in redactedScope) {
|
|
623
683
|
delete redactedScope.settingIds;
|
|
624
684
|
restricted = true;
|
|
@@ -626,6 +686,90 @@ function redactAiCallContext(record, permissions) {
|
|
|
626
686
|
result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
|
|
627
687
|
return result;
|
|
628
688
|
}
|
|
689
|
+
const proseRestrictedPlaceholder = "(正文读取权限受限)";
|
|
690
|
+
function redactContinuationGuard(record, permissions) {
|
|
691
|
+
if (permissions.prose !== "none")
|
|
692
|
+
return record;
|
|
693
|
+
return {
|
|
694
|
+
...record,
|
|
695
|
+
issues: [],
|
|
696
|
+
contextRefs: {},
|
|
697
|
+
failure: null,
|
|
698
|
+
restricted: true
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/** 无正文读取权限时移除建议中的原文、指令和检查证据,避免通过 AI 接口绕过 prose=none。 */
|
|
702
|
+
function redactSuggestion(record, permissions) {
|
|
703
|
+
if (permissions.prose !== "none")
|
|
704
|
+
return record;
|
|
705
|
+
const guard = recordValue(record.guard);
|
|
706
|
+
return {
|
|
707
|
+
...record,
|
|
708
|
+
instruction: proseRestrictedPlaceholder,
|
|
709
|
+
sourceText: "",
|
|
710
|
+
...(guard ? { guard: redactContinuationGuard(guard, permissions) } : {}),
|
|
711
|
+
restricted: true
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function redactAiConversationMessage(item, permissions) {
|
|
715
|
+
if (permissions.prose !== "none")
|
|
716
|
+
return item;
|
|
717
|
+
const message = recordValue(item);
|
|
718
|
+
if (!message)
|
|
719
|
+
return item;
|
|
720
|
+
return {
|
|
721
|
+
...message,
|
|
722
|
+
content: proseRestrictedPlaceholder,
|
|
723
|
+
citations: [],
|
|
724
|
+
metadata: { restricted: true },
|
|
725
|
+
restricted: true
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/** 无正文读取权限时隐藏对话预览与消息正文,避免历史对话泄露章节原文。 */
|
|
729
|
+
function redactAiConversation(record, permissions) {
|
|
730
|
+
const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
|
|
731
|
+
const scopedRecord = redactAiCallContext(readableRecord, permissions);
|
|
732
|
+
if (permissions.prose !== "none")
|
|
733
|
+
return scopedRecord;
|
|
734
|
+
const result = {
|
|
735
|
+
...scopedRecord,
|
|
736
|
+
title: proseRestrictedPlaceholder
|
|
737
|
+
};
|
|
738
|
+
if (typeof result.preview === "string" && result.preview.length > 0) {
|
|
739
|
+
result.preview = proseRestrictedPlaceholder;
|
|
740
|
+
}
|
|
741
|
+
if (Array.isArray(result.messages)) {
|
|
742
|
+
result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
|
|
743
|
+
}
|
|
744
|
+
const messagesPage = recordValue(result.messagesPage);
|
|
745
|
+
if (messagesPage && Array.isArray(messagesPage.items)) {
|
|
746
|
+
result.messagesPage = {
|
|
747
|
+
...messagesPage,
|
|
748
|
+
items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return { ...result, restricted: true };
|
|
752
|
+
}
|
|
753
|
+
/** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
|
|
754
|
+
export function publicAiStreamError(error) {
|
|
755
|
+
if (error instanceof AppError) {
|
|
756
|
+
const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
|
|
757
|
+
? error.details
|
|
758
|
+
: null;
|
|
759
|
+
return {
|
|
760
|
+
code: error.code,
|
|
761
|
+
message: error.message,
|
|
762
|
+
status: error.status,
|
|
763
|
+
...((error.status < 500 || error.code === "AI_CALL_FAILED") && typeof details?.failure === "string" ? { failure: details.failure } : {}),
|
|
764
|
+
...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
|
|
765
|
+
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
766
|
+
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
767
|
+
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
768
|
+
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
|
|
772
|
+
}
|
|
629
773
|
function redactMergeRecords(value, mapper) {
|
|
630
774
|
const record = recordValue(value);
|
|
631
775
|
if (!record)
|
|
@@ -652,6 +796,7 @@ export function createRuntime(options) {
|
|
|
652
796
|
sameOriginEnforced: options.security?.enforceSameOrigin ?? true
|
|
653
797
|
});
|
|
654
798
|
const database = new Database(options.databasePath);
|
|
799
|
+
const bootId = randomUUID();
|
|
655
800
|
const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
|
|
656
801
|
? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
|
|
657
802
|
: null;
|
|
@@ -722,6 +867,7 @@ export function createRuntime(options) {
|
|
|
722
867
|
}, false, actor?.allowAdminAccess ?? false);
|
|
723
868
|
});
|
|
724
869
|
const app = express();
|
|
870
|
+
enforceCaseInsensitiveRouting(app);
|
|
725
871
|
const upload = multer({
|
|
726
872
|
storage: multer.memoryStorage(),
|
|
727
873
|
limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
|
|
@@ -742,22 +888,28 @@ export function createRuntime(options) {
|
|
|
742
888
|
limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
|
|
743
889
|
});
|
|
744
890
|
app.disable("x-powered-by");
|
|
745
|
-
|
|
746
|
-
|
|
891
|
+
const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
|
|
892
|
+
if (options.security?.trustProxy === true) {
|
|
893
|
+
logger.warn("security.trust_proxy.coerced", { from: true, to: 1 });
|
|
894
|
+
}
|
|
895
|
+
if (trustProxy !== undefined)
|
|
896
|
+
app.set("trust proxy", trustProxy);
|
|
747
897
|
app.use(createRequestLoggingMiddleware());
|
|
748
898
|
app.use(createSecurityHeadersMiddleware());
|
|
749
899
|
app.get("/api/health", (_request, response) => {
|
|
750
900
|
data(response, {
|
|
751
901
|
status: "ok",
|
|
902
|
+
bootId,
|
|
752
903
|
version: APP_VERSION,
|
|
753
904
|
protocol: "openai-chat-completions",
|
|
754
|
-
protocols: [
|
|
905
|
+
protocols: [...AI_PROVIDER_PROTOCOLS],
|
|
755
906
|
development: options.developmentServer === true
|
|
756
907
|
});
|
|
757
908
|
});
|
|
758
909
|
if (options.security?.auth)
|
|
759
910
|
app.use(createBasicAuthMiddleware(options.security.auth));
|
|
760
911
|
app.use(createAuthenticationRateLimitMiddleware());
|
|
912
|
+
app.use(createCaptchaRateLimitMiddleware());
|
|
761
913
|
app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
|
|
762
914
|
if (options.security?.enforceSameOrigin ?? true)
|
|
763
915
|
app.use(createSameOriginMiddleware());
|
|
@@ -769,12 +921,12 @@ export function createRuntime(options) {
|
|
|
769
921
|
const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
|
|
770
922
|
const developmentUser = getDevelopmentUser();
|
|
771
923
|
if (!session && developmentUser) {
|
|
772
|
-
data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
924
|
+
data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
773
925
|
return;
|
|
774
926
|
}
|
|
775
927
|
data(response, session
|
|
776
|
-
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, setupTokenRequired: false, registrationOpen }
|
|
777
|
-
: { authenticated: false, user: null, csrfToken: null, setupRequired, setupTokenRequired, registrationOpen });
|
|
928
|
+
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
|
|
929
|
+
: { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
|
|
778
930
|
});
|
|
779
931
|
app.get("/api/auth/captcha", (_request, response) => {
|
|
780
932
|
data(response, captcha.create());
|
|
@@ -808,6 +960,7 @@ export function createRuntime(options) {
|
|
|
808
960
|
resolveBypassUser: getDevelopmentUser
|
|
809
961
|
}));
|
|
810
962
|
app.use(createUploadRateLimitMiddleware());
|
|
963
|
+
app.use(createExpensiveApiRateLimitMiddleware());
|
|
811
964
|
app.use(createCliApiScopeMiddleware(options.disableUserAuth));
|
|
812
965
|
app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
|
|
813
966
|
app.get("/api/cli/session", (request, response) => {
|
|
@@ -916,6 +1069,8 @@ export function createRuntime(options) {
|
|
|
916
1069
|
app.patch("/api/users/:userId", (request, response) => {
|
|
917
1070
|
if (!request.authUser)
|
|
918
1071
|
throw new AppError(401, "AUTH_REQUIRED", "请先登录");
|
|
1072
|
+
if (request.authUser.role !== "admin")
|
|
1073
|
+
throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
|
|
919
1074
|
const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
|
|
920
1075
|
store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
|
|
921
1076
|
data(response, updated);
|
|
@@ -1780,19 +1935,47 @@ export function createRuntime(options) {
|
|
|
1780
1935
|
page: request.query.page ?? "1",
|
|
1781
1936
|
limit: request.query.limit ?? "20"
|
|
1782
1937
|
}) ?? { page: 1, limit: 20, offset: 0 };
|
|
1783
|
-
|
|
1938
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1939
|
+
data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
|
|
1784
1940
|
});
|
|
1785
1941
|
app.post("/api/works/:workId/ai-conversations", (request, response) => {
|
|
1786
|
-
const input = parse(z.object({
|
|
1787
|
-
|
|
1942
|
+
const input = parse(z.object({
|
|
1943
|
+
title: z.string().max(200).optional(),
|
|
1944
|
+
taskType: aiConversationTaskTypeSchema.optional()
|
|
1945
|
+
}).strict(), request.body ?? {});
|
|
1946
|
+
data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
|
|
1788
1947
|
});
|
|
1789
1948
|
app.get("/api/ai-conversations/:conversationId", (request, response) => {
|
|
1790
1949
|
const pagination = parsePagination(request.query);
|
|
1791
|
-
|
|
1950
|
+
const conversation = pagination
|
|
1951
|
+
? store.getAiConversationPage(request.params.conversationId, pagination)
|
|
1952
|
+
: store.getAiConversation(request.params.conversationId);
|
|
1953
|
+
const permissions = requestPermissions(request, String(conversation.workId));
|
|
1954
|
+
data(response, redactAiConversation(conversation, permissions));
|
|
1792
1955
|
});
|
|
1793
1956
|
app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
|
|
1794
1957
|
const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
|
|
1795
|
-
|
|
1958
|
+
const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
|
|
1959
|
+
const permissions = requestPermissions(request, String(forked.workId));
|
|
1960
|
+
data(response, redactAiConversation(forked, permissions), 201);
|
|
1961
|
+
});
|
|
1962
|
+
app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
|
|
1963
|
+
const input = parse(z.object({ taskType: aiConversationTaskTypeSchema }).strict(), request.body);
|
|
1964
|
+
const updated = store.setAiConversationTaskType(request.params.conversationId, input.taskType);
|
|
1965
|
+
const permissions = requestPermissions(request, String(updated.workId));
|
|
1966
|
+
data(response, redactAiConversation(updated, permissions));
|
|
1967
|
+
});
|
|
1968
|
+
app.patch("/api/ai-conversations/:conversationId/context-scope", (request, response) => {
|
|
1969
|
+
const input = parse(z.object({ scope: contextSchema }).strict(), request.body);
|
|
1970
|
+
const updated = store.setAiConversationContextScope(request.params.conversationId, input.scope);
|
|
1971
|
+
const permissions = requestPermissions(request, String(updated.workId));
|
|
1972
|
+
data(response, redactAiConversation(updated, permissions));
|
|
1973
|
+
});
|
|
1974
|
+
app.patch("/api/ai-conversations/:conversationId/roleplay", (request, response) => {
|
|
1975
|
+
const input = parse(z.object({ characterId: identifier.nullable() }).strict(), request.body);
|
|
1976
|
+
const updated = store.setAiConversationRoleplayCharacter(request.params.conversationId, input.characterId);
|
|
1977
|
+
const permissions = requestPermissions(request, String(updated.workId));
|
|
1978
|
+
data(response, redactAiConversation(updated, permissions));
|
|
1796
1979
|
});
|
|
1797
1980
|
app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
|
|
1798
1981
|
const input = parse(z.object({
|
|
@@ -1809,7 +1992,10 @@ export function createRuntime(options) {
|
|
|
1809
1992
|
processSteps: z.array(aiProcessStepSchema).max(50).optional()
|
|
1810
1993
|
}).optional()
|
|
1811
1994
|
}), request.body);
|
|
1812
|
-
|
|
1995
|
+
const message = store.addAiConversationMessage(request.params.conversationId, input);
|
|
1996
|
+
const conversation = store.getAiConversationSummary(request.params.conversationId);
|
|
1997
|
+
const permissions = requestPermissions(request, String(conversation.workId));
|
|
1998
|
+
data(response, redactAiConversationMessage(message, permissions), 201);
|
|
1813
1999
|
});
|
|
1814
2000
|
app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
|
|
1815
2001
|
const input = parse(z.object({
|
|
@@ -1857,7 +2043,7 @@ export function createRuntime(options) {
|
|
|
1857
2043
|
data(response, ai.createProvider(parse(providerSchema, request.body)), 201);
|
|
1858
2044
|
});
|
|
1859
2045
|
app.get("/api/providers/:providerId", (request, response) => data(response, ai.getProvider(request.params.providerId)));
|
|
1860
|
-
app.patch("/api/providers/:providerId", (request, response) => data(response, ai.updateProvider(request.params.providerId, parse(
|
|
2046
|
+
app.patch("/api/providers/:providerId", (request, response) => data(response, ai.updateProvider(request.params.providerId, parse(providerUpdateSchema, request.body))));
|
|
1861
2047
|
app.delete("/api/providers/:providerId", (request, response) => {
|
|
1862
2048
|
ai.deleteProvider(request.params.providerId);
|
|
1863
2049
|
noContent(response);
|
|
@@ -1891,7 +2077,10 @@ export function createRuntime(options) {
|
|
|
1891
2077
|
app.get("/api/works/:workId/suggestions", (request, response) => {
|
|
1892
2078
|
const status = typeof request.query.status === "string" ? request.query.status : undefined;
|
|
1893
2079
|
const pagination = parsePagination(request.query);
|
|
1894
|
-
|
|
2080
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
2081
|
+
data(response, pagination
|
|
2082
|
+
? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
|
|
2083
|
+
: ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
|
|
1895
2084
|
});
|
|
1896
2085
|
app.post("/api/works/:workId/suggestions", async (request, response) => {
|
|
1897
2086
|
const input = parse(z.object({
|
|
@@ -1907,14 +2096,14 @@ export function createRuntime(options) {
|
|
|
1907
2096
|
if (store.getChapter(citation.chapterId).workId !== request.params.workId)
|
|
1908
2097
|
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
1909
2098
|
}
|
|
1910
|
-
data(response, await ai.createSuggestion({
|
|
2099
|
+
data(response, redactSuggestion(await ai.createSuggestion({
|
|
1911
2100
|
workId: request.params.workId,
|
|
1912
2101
|
taskType: input.taskType,
|
|
1913
2102
|
instruction: instructionWithCitations(input.instruction, citations),
|
|
1914
2103
|
scope: input.scope,
|
|
1915
2104
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
|
1916
2105
|
...(input.parameters ? { parameters: input.parameters } : {})
|
|
1917
|
-
}), 201);
|
|
2106
|
+
}), requestPermissions(request, request.params.workId)), 201);
|
|
1918
2107
|
});
|
|
1919
2108
|
app.post("/api/works/:workId/chat/stream", async (request, response) => {
|
|
1920
2109
|
const input = parse(z.object({
|
|
@@ -1955,6 +2144,7 @@ export function createRuntime(options) {
|
|
|
1955
2144
|
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
1956
2145
|
}
|
|
1957
2146
|
const conversationId = String(conversation.id);
|
|
2147
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1958
2148
|
const prepared = await ai.prepareConversationContext({
|
|
1959
2149
|
conversationId,
|
|
1960
2150
|
workId: request.params.workId,
|
|
@@ -1965,10 +2155,10 @@ export function createRuntime(options) {
|
|
|
1965
2155
|
});
|
|
1966
2156
|
sendEvent("context", {
|
|
1967
2157
|
...prepared,
|
|
1968
|
-
conversation: {
|
|
2158
|
+
conversation: redactAiConversation({
|
|
1969
2159
|
...store.getAiConversationSummary(conversationId),
|
|
1970
2160
|
contextWarningPending: prepared.action === "warn"
|
|
1971
|
-
}
|
|
2161
|
+
}, permissions)
|
|
1972
2162
|
});
|
|
1973
2163
|
if (prepared.action === "warn")
|
|
1974
2164
|
return;
|
|
@@ -1981,7 +2171,7 @@ export function createRuntime(options) {
|
|
|
1981
2171
|
});
|
|
1982
2172
|
const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
|
|
1983
2173
|
if (userMessage)
|
|
1984
|
-
sendEvent("user_message", { message: userMessage });
|
|
2174
|
+
sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
|
|
1985
2175
|
const suggestion = await ai.createStreamingChat({
|
|
1986
2176
|
workId: request.params.workId,
|
|
1987
2177
|
instruction: instructionWithCitations(input.instruction, citations),
|
|
@@ -2019,20 +2209,11 @@ export function createRuntime(options) {
|
|
|
2019
2209
|
}
|
|
2020
2210
|
catch (error) {
|
|
2021
2211
|
if (!controller.signal.aborted) {
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
:
|
|
2025
|
-
sendEvent("error", {
|
|
2026
|
-
code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
|
|
2027
|
-
message: error instanceof Error ? error.message : "AI 流式调用失败",
|
|
2028
|
-
...(error instanceof AppError ? { status: error.status } : {}),
|
|
2029
|
-
...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
|
|
2030
|
-
...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
|
|
2031
|
-
...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
|
|
2032
|
-
...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
|
|
2033
|
-
...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
|
|
2034
|
-
...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
|
|
2212
|
+
logger.error("ai.stream.failed", {
|
|
2213
|
+
workId: request.params.workId,
|
|
2214
|
+
error: sanitizeError(error)
|
|
2035
2215
|
});
|
|
2216
|
+
sendEvent("error", publicAiStreamError(error));
|
|
2036
2217
|
}
|
|
2037
2218
|
}
|
|
2038
2219
|
finally {
|
|
@@ -2040,22 +2221,34 @@ export function createRuntime(options) {
|
|
|
2040
2221
|
response.end();
|
|
2041
2222
|
}
|
|
2042
2223
|
});
|
|
2043
|
-
app.get("/api/suggestions/:suggestionId", (request, response) =>
|
|
2224
|
+
app.get("/api/suggestions/:suggestionId", (request, response) => {
|
|
2225
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2226
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2227
|
+
data(response, redactSuggestion(suggestion, permissions));
|
|
2228
|
+
});
|
|
2044
2229
|
app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
|
|
2045
2230
|
const pagination = parsePagination(request.query);
|
|
2046
|
-
|
|
2231
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2232
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2233
|
+
data(response, mapRecords(pagination
|
|
2047
2234
|
? store.listContinuationGuardsPage(request.params.suggestionId, pagination)
|
|
2048
|
-
: store.listContinuationGuards(request.params.suggestionId));
|
|
2235
|
+
: store.listContinuationGuards(request.params.suggestionId), (guard) => redactContinuationGuard(guard, permissions)));
|
|
2049
2236
|
});
|
|
2050
2237
|
app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
|
|
2051
2238
|
const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
|
|
2052
|
-
|
|
2239
|
+
const suggestion = ai.getSuggestion(request.params.suggestionId);
|
|
2240
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2241
|
+
data(response, redactContinuationGuard(await ai.runSuggestionGuard(request.params.suggestionId, input.content), permissions), 201);
|
|
2053
2242
|
});
|
|
2054
2243
|
app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
|
|
2055
2244
|
const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
|
|
2056
2245
|
data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
|
|
2057
2246
|
});
|
|
2058
|
-
app.post("/api/suggestions/:suggestionId/reject", (request, response) =>
|
|
2247
|
+
app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
|
|
2248
|
+
const suggestion = ai.rejectSuggestion(request.params.suggestionId);
|
|
2249
|
+
const permissions = requestPermissions(request, String(suggestion.workId));
|
|
2250
|
+
data(response, redactSuggestion(suggestion, permissions));
|
|
2251
|
+
});
|
|
2059
2252
|
app.get("/api/works/:workId/ai-calls", (request, response) => {
|
|
2060
2253
|
const pagination = parsePagination(request.query);
|
|
2061
2254
|
const permissions = requestPermissions(request, request.params.workId);
|
|
@@ -2072,7 +2265,7 @@ export function createRuntime(options) {
|
|
|
2072
2265
|
data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
|
|
2073
2266
|
});
|
|
2074
2267
|
app.get("/api/works/:workId/export", async (request, response) => {
|
|
2075
|
-
const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
|
|
2268
|
+
const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
|
|
2076
2269
|
if (format === "json") {
|
|
2077
2270
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
|
|
2078
2271
|
data(response, store.exportWork(request.params.workId));
|
|
@@ -2092,6 +2285,12 @@ export function createRuntime(options) {
|
|
|
2092
2285
|
}), response);
|
|
2093
2286
|
return;
|
|
2094
2287
|
}
|
|
2288
|
+
if (format === "docx") {
|
|
2289
|
+
response.type("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
|
2290
|
+
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.docx`);
|
|
2291
|
+
response.send(await store.exportDocx(request.params.workId));
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2095
2294
|
response.type("text/plain");
|
|
2096
2295
|
response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
|
|
2097
2296
|
response.send(store.exportText(request.params.workId, format));
|
|
@@ -2151,7 +2350,7 @@ export function createRuntime(options) {
|
|
|
2151
2350
|
setHeaders: setStaticCacheControl
|
|
2152
2351
|
}));
|
|
2153
2352
|
app.get("/{*path}", (request, response, next) => {
|
|
2154
|
-
if (request.path.startsWith("/api/"))
|
|
2353
|
+
if (normalizeApiPath(request.path).startsWith("/api/"))
|
|
2155
2354
|
return next();
|
|
2156
2355
|
sendIndexHtml(request, response);
|
|
2157
2356
|
});
|