@musnows/scriverse 0.8.7 → 0.9.0
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 -1
- package/README.md +3 -1
- package/dist/ai-analysis-timeout.js +12 -0
- package/dist/ai-analysis-timeout.js.map +1 -0
- package/dist/ai.js +943 -222
- package/dist/ai.js.map +1 -1
- package/dist/app.js +340 -28
- package/dist/app.js.map +1 -1
- package/dist/database.js +220 -2
- package/dist/database.js.map +1 -1
- package/dist/desktop-protocol.js +21 -0
- package/dist/desktop-protocol.js.map +1 -0
- package/dist/offline-sync.js +436 -0
- package/dist/offline-sync.js.map +1 -0
- package/dist/public/ai-connectivity-test.js +5 -2
- package/dist/public/app.js +470 -76
- package/dist/public/index.html +24 -11
- package/dist/public/roleplay-turn.js +100 -0
- package/dist/public/styles.css +76 -17
- package/dist/public/toast-layer.d.ts +4 -0
- package/dist/public/toast-layer.js +5 -0
- package/dist/roleplay-turn.js +98 -0
- package/dist/roleplay-turn.js.map +1 -0
- package/dist/security.js +36 -9
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +13 -3
- package/dist/server-runtime.js.map +1 -1
- package/dist/storage-manifest.js +116 -0
- package/dist/storage-manifest.js.map +1 -0
- package/dist/store.js +152 -24
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +194 -35
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/app.js
CHANGED
|
@@ -10,6 +10,7 @@ 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_PROTOCOL_OPTIONS, AI_PROVIDER_PROTOCOLS, AI_THINKING_TYPES, MAX_TOKENS_PARAMETERS } from "./ai-protocol.js";
|
|
13
|
+
import { MAX_AI_ANALYSIS_TIMEOUT_SECONDS, MIN_AI_ANALYSIS_TIMEOUT_SECONDS } from "./ai-analysis-timeout.js";
|
|
13
14
|
import { aiConversationExportContentDisposition, exportAiConversationMarkdown } from "./ai-conversation-export.js";
|
|
14
15
|
import { DEFAULT_AI_CHAT_TAB_LIMIT } from "./ai-chat-tab-limit.js";
|
|
15
16
|
import { MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS, MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
|
|
@@ -29,10 +30,12 @@ import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./goog
|
|
|
29
30
|
import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, readableHybridSearchTypes } from "./hybrid-search.js";
|
|
30
31
|
import { applyImportFileHints, parseNovelText } from "./parser.js";
|
|
31
32
|
import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes, WORK_AGENT_TOOL_IDS } from "./store.js";
|
|
33
|
+
import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
|
|
32
34
|
import { paginated, parsePagination } from "./pagination.js";
|
|
33
35
|
import { normalizeUploadFileName } from "./utils.js";
|
|
34
36
|
import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
|
|
35
37
|
import { ImageCaptchaService } from "./image-captcha.js";
|
|
38
|
+
import { OfflineSyncService } from "./offline-sync.js";
|
|
36
39
|
import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
|
|
37
40
|
import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
|
|
38
41
|
import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logging.js";
|
|
@@ -40,6 +43,7 @@ import { accountReference, logger, sanitizeError } from "./logger.js";
|
|
|
40
43
|
import { currentRequestActor, runWithRequestActor } from "./request-context.js";
|
|
41
44
|
import { S3BackupManager } from "./s3-backup.js";
|
|
42
45
|
import { APP_VERSION } from "./version.js";
|
|
46
|
+
import { DESKTOP_SYNC_PROTOCOL, desktopCompatibilityMetadata } from "./desktop-protocol.js";
|
|
43
47
|
import { ReleaseUpdateChecker } from "./release-update.js";
|
|
44
48
|
import { CHARACTER_AVATAR_IMAGE_MAX_BYTES, DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
|
|
45
49
|
import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
@@ -126,6 +130,14 @@ const loginSchema = z.object({
|
|
|
126
130
|
password: z.string().max(200),
|
|
127
131
|
...captchaFields
|
|
128
132
|
}).strict();
|
|
133
|
+
const desktopLoginSchema = z.object({
|
|
134
|
+
username: z.string().trim().min(1).max(100),
|
|
135
|
+
password: z.string().max(200),
|
|
136
|
+
desktopId: z.string().uuid(),
|
|
137
|
+
profileId: z.string().uuid(),
|
|
138
|
+
clientVersion: z.string().trim().min(1).max(80),
|
|
139
|
+
...captchaFields
|
|
140
|
+
}).strict();
|
|
129
141
|
const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
|
|
130
142
|
const memberRoleValueSchema = z.enum(["editor", "settings-editor", "viewer"]);
|
|
131
143
|
const moduleAccessSchema = z.enum(["none", "read", "write"]);
|
|
@@ -201,6 +213,7 @@ const workSchema = z.object({
|
|
|
201
213
|
coverUrl: z.string().url().nullable().optional(),
|
|
202
214
|
tags: optionalStrings
|
|
203
215
|
});
|
|
216
|
+
const workOfflineAccessSchema = z.object({ enabled: z.boolean() }).strict();
|
|
204
217
|
const settingSchema = z.object({
|
|
205
218
|
title: nonEmpty.max(200),
|
|
206
219
|
category: nonEmpty.max(100),
|
|
@@ -212,6 +225,31 @@ const settingSchema = z.object({
|
|
|
212
225
|
scope: jsonObject.optional(),
|
|
213
226
|
authorNote: z.string().max(20_000).optional()
|
|
214
227
|
});
|
|
228
|
+
const chapterSyncSnapshotSchema = z.object({
|
|
229
|
+
title: nonEmpty.max(300),
|
|
230
|
+
content: z.string().max(2_000_000),
|
|
231
|
+
chapterType: chapterTypeSchema
|
|
232
|
+
}).strict();
|
|
233
|
+
const settingSyncSnapshotSchema = settingSchema.strict();
|
|
234
|
+
const syncMutationBaseFields = {
|
|
235
|
+
mutationId: z.string().uuid(),
|
|
236
|
+
entityId: identifier,
|
|
237
|
+
operation: z.literal("update"),
|
|
238
|
+
baseVersionNo: z.number().int().positive(),
|
|
239
|
+
changeNote: z.string().trim().min(1).max(500).default("Desktop 离线修改")
|
|
240
|
+
};
|
|
241
|
+
const syncPushSchema = z.object({
|
|
242
|
+
clientId: z.string().uuid(),
|
|
243
|
+
mutations: z.array(z.discriminatedUnion("entityType", [
|
|
244
|
+
z.object({ ...syncMutationBaseFields, entityType: z.literal("chapter"), localSnapshot: chapterSyncSnapshotSchema }).strict(),
|
|
245
|
+
z.object({ ...syncMutationBaseFields, entityType: z.literal("setting"), localSnapshot: settingSyncSnapshotSchema }).strict()
|
|
246
|
+
])).min(1).max(20)
|
|
247
|
+
}).strict().superRefine((input, context) => {
|
|
248
|
+
const mutationIds = input.mutations.map((mutation) => mutation.mutationId);
|
|
249
|
+
if (new Set(mutationIds).size !== mutationIds.length) {
|
|
250
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ["mutations"], message: "同一批次不能重复 mutationId" });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
215
253
|
const globalReplaceSchema = z.object({
|
|
216
254
|
find: z.string().min(1).max(500),
|
|
217
255
|
replacement: z.string().max(200_000),
|
|
@@ -378,6 +416,7 @@ const providerBaseSchema = z.object({
|
|
|
378
416
|
note: z.string().max(10_000).optional(),
|
|
379
417
|
concurrencyLimit: z.number().int().min(1).max(100).optional(),
|
|
380
418
|
rpmLimit: z.number().int().min(1).max(10_000).optional(),
|
|
419
|
+
analysisTimeoutSeconds: z.number().int().min(MIN_AI_ANALYSIS_TIMEOUT_SECONDS).max(MAX_AI_ANALYSIS_TIMEOUT_SECONDS).optional(),
|
|
381
420
|
dailyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
|
|
382
421
|
monthlyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional()
|
|
383
422
|
});
|
|
@@ -603,6 +642,51 @@ const contextSchema = z.object({
|
|
|
603
642
|
includeBookSummary: z.boolean().optional(),
|
|
604
643
|
includeSettingInfo: z.boolean().optional()
|
|
605
644
|
});
|
|
645
|
+
const desktopLocalAiRuntimeModelSchema = z.object({
|
|
646
|
+
id: identifier,
|
|
647
|
+
providerId: identifier,
|
|
648
|
+
providerName: nonEmpty.max(200),
|
|
649
|
+
protocol: z.literal("openai-chat-completions"),
|
|
650
|
+
maxTokensParameter: z.enum(MAX_TOKENS_PARAMETERS),
|
|
651
|
+
thinkingType: z.enum(["enabled", "adaptive"]),
|
|
652
|
+
concurrencyLimit: z.number().int().min(1).max(100),
|
|
653
|
+
rpmLimit: z.number().int().min(1).max(10_000),
|
|
654
|
+
analysisTimeoutSeconds: z.number().int().min(MIN_AI_ANALYSIS_TIMEOUT_SECONDS).max(MAX_AI_ANALYSIS_TIMEOUT_SECONDS),
|
|
655
|
+
displayName: nonEmpty.max(200),
|
|
656
|
+
modelId: nonEmpty.max(300),
|
|
657
|
+
purposes: z.array(z.enum(TASK_TYPES)).min(1).max(TASK_TYPES.length),
|
|
658
|
+
contextNote: z.string().max(10_000),
|
|
659
|
+
contextWindow: z.number().int().min(32_768).max(2_000_000),
|
|
660
|
+
outputNote: z.string().max(10_000),
|
|
661
|
+
preset: z.object({
|
|
662
|
+
temperature: z.number().min(0).max(2),
|
|
663
|
+
max_tokens: z.number().int().min(1).max(2_000_000)
|
|
664
|
+
}).strict(),
|
|
665
|
+
thinkingEnabled: z.boolean(),
|
|
666
|
+
thinkingEffort: z.enum(["default", "auto", "low", "medium", "high", "xhigh", "max"]),
|
|
667
|
+
multimodalEnabled: z.boolean(),
|
|
668
|
+
note: z.string().max(10_000)
|
|
669
|
+
}).strict().refine((input) => input.preset.max_tokens < input.contextWindow, {
|
|
670
|
+
path: ["preset", "max_tokens"],
|
|
671
|
+
message: "本地 AI 最大输出令牌数必须小于上下文窗口"
|
|
672
|
+
});
|
|
673
|
+
const desktopLocalAiRunSchema = z.object({
|
|
674
|
+
taskType: z.enum(["chat", "continue", "polish"]),
|
|
675
|
+
instruction: nonEmpty.max(100_000),
|
|
676
|
+
scope: contextSchema,
|
|
677
|
+
runtimeModel: desktopLocalAiRuntimeModelSchema,
|
|
678
|
+
conversationId: identifier.optional(),
|
|
679
|
+
currentMessageId: identifier.optional(),
|
|
680
|
+
citations: aiCitationsSchema.optional(),
|
|
681
|
+
imageAttachmentIds: z.array(identifier).max(4).optional(),
|
|
682
|
+
sceneDirection: z.string().max(20_000).optional()
|
|
683
|
+
}).strict();
|
|
684
|
+
const desktopLocalAiCompletionResponseSchema = z.object({
|
|
685
|
+
requestId: identifier,
|
|
686
|
+
status: z.number().int().min(100).max(599),
|
|
687
|
+
body: z.string().max(4 * 1024 * 1024),
|
|
688
|
+
retryAfter: z.string().max(500).optional()
|
|
689
|
+
}).strict();
|
|
606
690
|
/** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
|
|
607
691
|
export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
|
|
608
692
|
const relationshipSourceRefSchema = z.object({
|
|
@@ -757,6 +841,9 @@ function data(response, value, status = 200) {
|
|
|
757
841
|
function noContent(response) {
|
|
758
842
|
response.status(204).end();
|
|
759
843
|
}
|
|
844
|
+
function hasInteractiveSession(request) {
|
|
845
|
+
return request.authMethod === "session" || request.authMethod === "desktop-session";
|
|
846
|
+
}
|
|
760
847
|
async function sendEpub(response, archive, title, fallbackStem) {
|
|
761
848
|
response.type(EPUB_MIME_TYPE);
|
|
762
849
|
response.setHeader("Content-Disposition", epubContentDisposition(title, fallbackStem));
|
|
@@ -1004,7 +1091,16 @@ function redactAiConversation(record, permissions) {
|
|
|
1004
1091
|
result.agentTools = [
|
|
1005
1092
|
...(permissions.characters !== "none" ? ["recall_self"] : []),
|
|
1006
1093
|
...(permissions.characters !== "none" && permissions.relationships !== "none" ? ["recall_relationship"] : []),
|
|
1094
|
+
...(permissions.characters !== "none"
|
|
1095
|
+
&& (permissions.relationships !== "none" || permissions.organizations !== "none" || permissions.timeline !== "none")
|
|
1096
|
+
? ["recall_other"]
|
|
1097
|
+
: []),
|
|
1098
|
+
...((permissions.races !== "none" || permissions.organizations !== "none" || permissions.settings !== "none") ? ["recall_known"] : []),
|
|
1007
1099
|
...(permissions.prose !== "none" ? ["recall_story"] : []),
|
|
1100
|
+
...["settings", "characters", "races", "organizations", "timeline", "relationships", "outlines"]
|
|
1101
|
+
.some((module) => permissions[module] !== "none")
|
|
1102
|
+
? ["image"]
|
|
1103
|
+
: [],
|
|
1008
1104
|
"calculate_time"
|
|
1009
1105
|
];
|
|
1010
1106
|
}
|
|
@@ -1105,7 +1201,8 @@ export function createRuntime(options) {
|
|
|
1105
1201
|
const characterAvatarStorage = new AttachmentStorage(options.characterAvatarDirectory
|
|
1106
1202
|
?? (temporaryStorageRoot ? join(temporaryStorageRoot, "character-avatars") : join(dirname(options.databasePath), "character-avatars")), CHARACTER_AVATAR_IMAGE_MAX_BYTES);
|
|
1107
1203
|
mkdirSync(characterAvatarStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
|
|
1108
|
-
const
|
|
1204
|
+
const credentialVault = new CredentialVault(options.masterSecret);
|
|
1205
|
+
const auth = new UserAuthService(database, credentialVault);
|
|
1109
1206
|
const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
|
|
1110
1207
|
const publishCollaborativeChange = (workId, pageKey, options = {}) => {
|
|
1111
1208
|
const actor = currentRequestActor();
|
|
@@ -1136,6 +1233,7 @@ export function createRuntime(options) {
|
|
|
1136
1233
|
? auth.listUsers().find((user) => user.status === "active") ?? null
|
|
1137
1234
|
: null;
|
|
1138
1235
|
const store = new Store(database);
|
|
1236
|
+
const offlineSync = new OfflineSyncService(database, store);
|
|
1139
1237
|
const platformAiSettings = store.getPlatformAiSettings();
|
|
1140
1238
|
const platformAiStreamIdleTimeoutMs = normalizeAiStreamIdleTimeoutSeconds(Number(platformAiSettings.streamIdleTimeoutSeconds)) * 1_000;
|
|
1141
1239
|
let attachmentCleanupChain = Promise.resolve();
|
|
@@ -1197,7 +1295,6 @@ export function createRuntime(options) {
|
|
|
1197
1295
|
return lockedModelId ?? requestedModelId;
|
|
1198
1296
|
};
|
|
1199
1297
|
const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
|
|
1200
|
-
const credentialVault = new CredentialVault(options.masterSecret);
|
|
1201
1298
|
const backups = new S3BackupManager(database, credentialVault, store, attachmentStorage, {
|
|
1202
1299
|
...options.backupOptions,
|
|
1203
1300
|
characterAvatarStorage,
|
|
@@ -1215,11 +1312,11 @@ export function createRuntime(options) {
|
|
|
1215
1312
|
});
|
|
1216
1313
|
if (!options.liteLlmPriceCache && options.liteLlmPriceCachePath)
|
|
1217
1314
|
liteLlmPriceCache.start();
|
|
1218
|
-
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
|
|
1315
|
+
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true || options.disableAiEndpointValidation === true
|
|
1219
1316
|
? undefined
|
|
1220
1317
|
: options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
|
|
1221
1318
|
const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
|
|
1222
|
-
const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
|
|
1319
|
+
const creator = actor ? null : database.get("SELECT created_by_user_id, created_via_api_key FROM analysis_tasks WHERE id = ?", String(task.id));
|
|
1223
1320
|
const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
|
|
1224
1321
|
if (!userId)
|
|
1225
1322
|
return;
|
|
@@ -1229,13 +1326,14 @@ export function createRuntime(options) {
|
|
|
1229
1326
|
auth.assertWorkAccess(user, String(task.workId), {
|
|
1230
1327
|
read: requiredModules,
|
|
1231
1328
|
write: ["ai-analysis"]
|
|
1232
|
-
}, false, actor?.allowAdminAccess ??
|
|
1329
|
+
}, false, actor?.allowAdminAccess ?? (user.role === "admin" && Number(creator?.created_via_api_key) !== 1));
|
|
1233
1330
|
}, attachmentStorage, {
|
|
1234
1331
|
interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? platformAiStreamIdleTimeoutMs,
|
|
1235
1332
|
retryPolicy: options.aiRetryPolicy,
|
|
1236
1333
|
retrySleep: options.aiRetrySleep,
|
|
1237
1334
|
aiChatImageMaxBytes: uploadLimits.chatImageBytes,
|
|
1238
|
-
liteLlmPriceCache
|
|
1335
|
+
liteLlmPriceCache,
|
|
1336
|
+
allowPrivateAiEndpoints: options.security?.allowPrivateAiEndpoints === true
|
|
1239
1337
|
});
|
|
1240
1338
|
const app = express();
|
|
1241
1339
|
enforceCaseInsensitiveRouting(app);
|
|
@@ -1279,6 +1377,7 @@ export function createRuntime(options) {
|
|
|
1279
1377
|
status: "ok",
|
|
1280
1378
|
bootId,
|
|
1281
1379
|
version: APP_VERSION,
|
|
1380
|
+
...desktopCompatibilityMetadata(),
|
|
1282
1381
|
versionLabel: options.betaVersionLabel ?? null,
|
|
1283
1382
|
protocol: "openai-chat-completions",
|
|
1284
1383
|
protocols: [...AI_PROVIDER_PROTOCOLS],
|
|
@@ -1297,9 +1396,11 @@ export function createRuntime(options) {
|
|
|
1297
1396
|
app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
|
|
1298
1397
|
if (options.security?.enforceSameOrigin ?? true)
|
|
1299
1398
|
app.use(createSameOriginMiddleware());
|
|
1399
|
+
app.use("/api/sync/works", express.json({ limit: "3mb" }));
|
|
1300
1400
|
app.use(express.json({ limit: "2mb" }));
|
|
1301
1401
|
app.get("/api/auth/session", (request, response) => {
|
|
1302
|
-
const
|
|
1402
|
+
const desktopSession = auth.authenticateDesktop(request);
|
|
1403
|
+
const session = desktopSession ? null : auth.authenticate(request);
|
|
1303
1404
|
const registrationOpen = options.security?.allowRegistration === true;
|
|
1304
1405
|
const setupRequired = !auth.hasUsers();
|
|
1305
1406
|
const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
|
|
@@ -1308,8 +1409,17 @@ export function createRuntime(options) {
|
|
|
1308
1409
|
data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
1309
1410
|
return;
|
|
1310
1411
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1412
|
+
const interactiveSession = desktopSession ?? session;
|
|
1413
|
+
data(response, interactiveSession
|
|
1414
|
+
? {
|
|
1415
|
+
authenticated: true,
|
|
1416
|
+
user: interactiveSession.user,
|
|
1417
|
+
csrfToken: desktopSession ? null : session.csrfToken,
|
|
1418
|
+
bootId,
|
|
1419
|
+
setupRequired: false,
|
|
1420
|
+
setupTokenRequired: false,
|
|
1421
|
+
registrationOpen
|
|
1422
|
+
}
|
|
1313
1423
|
: { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
|
|
1314
1424
|
});
|
|
1315
1425
|
app.get("/api/auth/captcha", (_request, response) => {
|
|
@@ -1339,6 +1449,19 @@ export function createRuntime(options) {
|
|
|
1339
1449
|
logger.info("auth.login.succeeded", { actorRef: accountReference(result.session.user.userId) });
|
|
1340
1450
|
data(response, { user: result.session.user, csrfToken: result.session.csrfToken });
|
|
1341
1451
|
});
|
|
1452
|
+
app.post("/api/desktop/auth/login", (request, response) => {
|
|
1453
|
+
const input = parse(desktopLoginSchema, request.body);
|
|
1454
|
+
captcha.consume(input.captchaId, input.captchaAnswer);
|
|
1455
|
+
const result = auth.loginDesktop(input.username, input.password, {
|
|
1456
|
+
desktopId: input.desktopId,
|
|
1457
|
+
profileId: input.profileId,
|
|
1458
|
+
clientVersion: input.clientVersion
|
|
1459
|
+
});
|
|
1460
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1461
|
+
runWithRequestActor(result.session.user, () => store.audit(null, "user.logged-in", "user", result.session.user.userId, { source: "desktop" }));
|
|
1462
|
+
logger.info("auth.desktop_login.succeeded", { actorRef: accountReference(result.session.user.userId) });
|
|
1463
|
+
data(response, { token: result.token, expiresAt: result.session.expiresAt, user: result.session.user });
|
|
1464
|
+
});
|
|
1342
1465
|
app.use(createUserSessionMiddleware(auth, {
|
|
1343
1466
|
disabled: options.disableUserAuth === true,
|
|
1344
1467
|
resolveBypassUser: getDevelopmentUser
|
|
@@ -1353,14 +1476,17 @@ export function createRuntime(options) {
|
|
|
1353
1476
|
data(response, { authenticated: true, user: request.authUser, apiKeyPrefix: request.authApiKey?.prefix ?? null });
|
|
1354
1477
|
});
|
|
1355
1478
|
app.delete("/api/auth/session", (request, response) => {
|
|
1356
|
-
if (request.
|
|
1479
|
+
if (request.authDesktopSession)
|
|
1480
|
+
auth.revokeDesktop(request.authDesktopSession.id);
|
|
1481
|
+
if (request.authSession) {
|
|
1357
1482
|
auth.revoke(request.authSession.id);
|
|
1358
|
-
|
|
1483
|
+
clearSessionCookie(response, request.secure);
|
|
1484
|
+
}
|
|
1359
1485
|
noContent(response);
|
|
1360
1486
|
});
|
|
1361
1487
|
app.post("/api/auth/onboarding/complete", (request, response) => {
|
|
1362
|
-
if (!request.authUser || request
|
|
1363
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1488
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1489
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话完成新手引导");
|
|
1364
1490
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1365
1491
|
const updated = auth.completeOnboarding(request.authUser.userId);
|
|
1366
1492
|
store.audit(null, "user.onboarding-completed", "user", updated.userId);
|
|
@@ -1410,21 +1536,38 @@ export function createRuntime(options) {
|
|
|
1410
1536
|
data(response, updated);
|
|
1411
1537
|
});
|
|
1412
1538
|
app.patch("/api/auth/password", (request, response) => {
|
|
1413
|
-
|
|
1539
|
+
const activeSession = request.authDesktopSession
|
|
1540
|
+
? { id: request.authDesktopSession.id, kind: "desktop" }
|
|
1541
|
+
: request.authSession
|
|
1542
|
+
? { id: request.authSession.id, kind: "browser" }
|
|
1543
|
+
: null;
|
|
1544
|
+
if (!request.authUser || !activeSession)
|
|
1414
1545
|
throw new AppError(401, "AUTH_REQUIRED", "请先登录");
|
|
1415
1546
|
const input = parse(passwordChangeSchema, request.body);
|
|
1416
|
-
auth.changePassword(request.authUser.userId,
|
|
1547
|
+
auth.changePassword(request.authUser.userId, activeSession.id, input.currentPassword, input.newPassword, activeSession.kind);
|
|
1417
1548
|
store.audit(null, "user.password-changed", "user", request.authUser.userId);
|
|
1418
1549
|
noContent(response);
|
|
1419
1550
|
});
|
|
1420
1551
|
app.get("/api/auth/api-key", (request, response) => {
|
|
1421
|
-
if (!request.authUser || request
|
|
1422
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1552
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1553
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1423
1554
|
data(response, auth.getApiKeyStatus(request.authUser.userId));
|
|
1424
1555
|
});
|
|
1556
|
+
app.post("/api/auth/api-key/reveal", (request, response) => {
|
|
1557
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1558
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1559
|
+
parse(z.object({}).strict(), request.body ?? {});
|
|
1560
|
+
const userId = request.authUser.userId;
|
|
1561
|
+
const revealed = database.transaction(() => {
|
|
1562
|
+
const secret = auth.revealApiKey(userId);
|
|
1563
|
+
store.audit(null, "user.api-key-copied", "user", userId, { prefix: secret.prefix });
|
|
1564
|
+
return { apiKey: secret.apiKey };
|
|
1565
|
+
});
|
|
1566
|
+
data(response, revealed);
|
|
1567
|
+
});
|
|
1425
1568
|
app.post("/api/auth/api-key/reset", (request, response) => {
|
|
1426
|
-
if (!request.authUser || request
|
|
1427
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1569
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1570
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1428
1571
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1429
1572
|
const userId = request.authUser.userId;
|
|
1430
1573
|
const result = database.transaction(() => {
|
|
@@ -1512,8 +1655,8 @@ export function createRuntime(options) {
|
|
|
1512
1655
|
data(response, pagination ? store.getWorkDirectoryPage(request.params.workId, pagination) : store.getWorkDirectory(request.params.workId));
|
|
1513
1656
|
});
|
|
1514
1657
|
app.post("/api/works/:workId/presence", (request, response) => {
|
|
1515
|
-
if (!request.authUser || request
|
|
1516
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1658
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1659
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话上报协作状态");
|
|
1517
1660
|
const input = parse(presenceHeartbeatSchema, request.body);
|
|
1518
1661
|
data(response, collaborationPresence.heartbeat(request.params.workId, input.clientId, {
|
|
1519
1662
|
userId: request.authUser.userId,
|
|
@@ -1564,6 +1707,77 @@ export function createRuntime(options) {
|
|
|
1564
1707
|
const { expectedVersionNo, changeNote, ...input } = parse(workSchema.partial().extend({ expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
|
|
1565
1708
|
data(response, store.updateWork(request.params.workId, input, expectedVersionNo, "manual", null, changeNote));
|
|
1566
1709
|
});
|
|
1710
|
+
app.patch("/api/works/:workId/offline-access", (request, response) => {
|
|
1711
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1712
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用网页或 Desktop 登录管理离线访问");
|
|
1713
|
+
}
|
|
1714
|
+
const input = parse(workOfflineAccessSchema, request.body);
|
|
1715
|
+
data(response, store.setWorkOfflineAccess(request.params.workId, input.enabled));
|
|
1716
|
+
});
|
|
1717
|
+
app.post("/api/sync/works/:workId/snapshots", (request, response) => {
|
|
1718
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1719
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录创建同步快照");
|
|
1720
|
+
}
|
|
1721
|
+
parse(z.object({}).strict(), request.body ?? {});
|
|
1722
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1723
|
+
data(response, offlineSync.createSnapshot(request.params.workId, request.authUser.userId), 201);
|
|
1724
|
+
});
|
|
1725
|
+
app.get("/api/sync/works/:workId/changes", (request, response) => {
|
|
1726
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1727
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步变更");
|
|
1728
|
+
}
|
|
1729
|
+
const query = parse(z.object({
|
|
1730
|
+
after: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
1731
|
+
limit: z.coerce.number().int().min(1).max(200).default(200)
|
|
1732
|
+
}).strict(), request.query);
|
|
1733
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1734
|
+
data(response, offlineSync.listChanges(request.params.workId, query.after, query.limit));
|
|
1735
|
+
});
|
|
1736
|
+
app.post("/api/sync/works/:workId/push", (request, response) => {
|
|
1737
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1738
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录提交离线变更");
|
|
1739
|
+
}
|
|
1740
|
+
if (Buffer.byteLength(JSON.stringify(request.body ?? {}), "utf8") > DESKTOP_SYNC_PROTOCOL.maxMutationBytes) {
|
|
1741
|
+
throw new AppError(413, "SYNC_PUSH_TOO_LARGE", `同步批次不能超过 ${DESKTOP_SYNC_PROTOCOL.maxMutationBytes} bytes`);
|
|
1742
|
+
}
|
|
1743
|
+
const input = parse(syncPushSchema, request.body);
|
|
1744
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1745
|
+
data(response, offlineSync.pushMutations(request.params.workId, request.authUser.userId, input.clientId, input.mutations));
|
|
1746
|
+
});
|
|
1747
|
+
app.get("/api/sync/works/:workId/mutations/:mutationId", (request, response) => {
|
|
1748
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1749
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步变更结果");
|
|
1750
|
+
}
|
|
1751
|
+
const mutationId = parse(z.string().uuid(), request.params.mutationId);
|
|
1752
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1753
|
+
data(response, offlineSync.getMutationResult(request.params.workId, request.authUser.userId, mutationId));
|
|
1754
|
+
});
|
|
1755
|
+
app.get("/api/sync/snapshots/:snapshotId/items", (request, response) => {
|
|
1756
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1757
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步快照");
|
|
1758
|
+
}
|
|
1759
|
+
const snapshotId = parse(z.string().uuid(), request.params.snapshotId);
|
|
1760
|
+
const snapshot = offlineSync.describeOwnedSnapshot(snapshotId, request.authUser.userId);
|
|
1761
|
+
auth.assertActiveWork(snapshot.workId);
|
|
1762
|
+
auth.assertWorkAccess(request.authUser, snapshot.workId, { read: ["prose", "settings"] }, false, true);
|
|
1763
|
+
const query = parse(z.object({
|
|
1764
|
+
after: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
1765
|
+
limit: z.coerce.number().int().min(1).max(100).default(100)
|
|
1766
|
+
}).strict(), request.query);
|
|
1767
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1768
|
+
data(response, offlineSync.readSnapshotPage(snapshotId, request.authUser.userId, query.after, query.limit));
|
|
1769
|
+
});
|
|
1770
|
+
app.delete("/api/sync/snapshots/:snapshotId", (request, response) => {
|
|
1771
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1772
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录删除同步快照");
|
|
1773
|
+
}
|
|
1774
|
+
const snapshotId = parse(z.string().uuid(), request.params.snapshotId);
|
|
1775
|
+
const snapshot = offlineSync.describeOwnedSnapshot(snapshotId, request.authUser.userId);
|
|
1776
|
+
auth.assertActiveWork(snapshot.workId);
|
|
1777
|
+
auth.assertWorkAccess(request.authUser, snapshot.workId, { read: ["prose", "settings"] }, false, true);
|
|
1778
|
+
offlineSync.deleteSnapshot(snapshotId, request.authUser.userId);
|
|
1779
|
+
noContent(response);
|
|
1780
|
+
});
|
|
1567
1781
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
1568
1782
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1569
1783
|
ai.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
@@ -1725,7 +1939,7 @@ export function createRuntime(options) {
|
|
|
1725
1939
|
});
|
|
1726
1940
|
app.get("/api/works/:workId/chapter-annotations", (request, response) => {
|
|
1727
1941
|
const pagination = parsePagination(request.query);
|
|
1728
|
-
const permissions = requestPermissions(request, request.params.workId);
|
|
1942
|
+
const permissions = requestPermissions(request, String(request.params.workId));
|
|
1729
1943
|
data(response, pagination
|
|
1730
1944
|
? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions))
|
|
1731
1945
|
: store.listWorkChapterAnnotations(request.params.workId, readableChapterAnnotationKinds(permissions)));
|
|
@@ -1866,6 +2080,10 @@ export function createRuntime(options) {
|
|
|
1866
2080
|
data(response, store.createDraft(request.params.workId, parse(draftSchema, request.body)), 201);
|
|
1867
2081
|
});
|
|
1868
2082
|
app.get("/api/drafts/:draftId", (request, response) => data(response, store.getDraft(request.params.draftId)));
|
|
2083
|
+
app.patch("/api/drafts/:draftId/favorite", (request, response) => {
|
|
2084
|
+
const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
|
|
2085
|
+
data(response, store.setDraftFavorite(request.params.draftId, input.isFavorite));
|
|
2086
|
+
});
|
|
1869
2087
|
app.patch("/api/drafts/:draftId", (request, response) => {
|
|
1870
2088
|
const { changeNote, expectedVersionNo, ...input } = parse(draftSchema.partial().extend({
|
|
1871
2089
|
changeNote: changeNoteSchema,
|
|
@@ -1889,6 +2107,12 @@ export function createRuntime(options) {
|
|
|
1889
2107
|
});
|
|
1890
2108
|
app.get("/api/works/:workId/settings/context", (request, response) => data(response, store.listSettings(request.params.workId, true)));
|
|
1891
2109
|
app.get("/api/settings/:settingId", (request, response) => data(response, store.getSetting(request.params.settingId)));
|
|
2110
|
+
app.patch("/api/settings/:settingId/favorite", (request, response) => {
|
|
2111
|
+
const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
|
|
2112
|
+
const setting = store.setSettingFavorite(request.params.settingId, input.isFavorite);
|
|
2113
|
+
publishEntityChange(String(setting.workId), "setting", String(setting.id));
|
|
2114
|
+
data(response, setting);
|
|
2115
|
+
});
|
|
1892
2116
|
app.patch("/api/settings/:settingId", (request, response) => {
|
|
1893
2117
|
const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
1894
2118
|
const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
|
|
@@ -1976,6 +2200,12 @@ export function createRuntime(options) {
|
|
|
1976
2200
|
}
|
|
1977
2201
|
data(response, redactCharacterLinks(result.character, requestPermissions(request)));
|
|
1978
2202
|
});
|
|
2203
|
+
app.patch("/api/characters/:characterId/favorite", (request, response) => {
|
|
2204
|
+
const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
|
|
2205
|
+
const character = store.setCharacterFavorite(request.params.characterId, input.isFavorite);
|
|
2206
|
+
publishEntityChange(String(character.workId), "character", String(character.id));
|
|
2207
|
+
data(response, redactCharacterLinks(character, requestPermissions(request)));
|
|
2208
|
+
});
|
|
1979
2209
|
app.patch("/api/characters/:characterId", (request, response) => {
|
|
1980
2210
|
const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
|
|
1981
2211
|
const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
|
|
@@ -2184,6 +2414,12 @@ export function createRuntime(options) {
|
|
|
2184
2414
|
app.get("/api/organizations/:organizationId", (request, response) => {
|
|
2185
2415
|
data(response, redactOrganizationMembers(store.getOrganization(request.params.organizationId), requestPermissions(request)));
|
|
2186
2416
|
});
|
|
2417
|
+
app.patch("/api/organizations/:organizationId/favorite", (request, response) => {
|
|
2418
|
+
const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
|
|
2419
|
+
const organization = store.setOrganizationFavorite(request.params.organizationId, input.isFavorite);
|
|
2420
|
+
publishEntityChange(String(organization.workId), "organization", String(organization.id));
|
|
2421
|
+
data(response, redactOrganizationMembers(organization, requestPermissions(request)));
|
|
2422
|
+
});
|
|
2187
2423
|
app.patch("/api/organizations/:organizationId", (request, response) => {
|
|
2188
2424
|
const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
|
|
2189
2425
|
const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
|
|
@@ -2799,6 +3035,61 @@ export function createRuntime(options) {
|
|
|
2799
3035
|
? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
|
|
2800
3036
|
: ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
|
|
2801
3037
|
});
|
|
3038
|
+
const desktopLocalAiActorScope = (request) => {
|
|
3039
|
+
const actor = currentRequestActor();
|
|
3040
|
+
return request.authUser?.userId ? `user:${request.authUser.userId}` : actor?.userId ? `user:${actor.userId}` : "auth-disabled";
|
|
3041
|
+
};
|
|
3042
|
+
const assertDesktopLocalAiPermission = (request) => {
|
|
3043
|
+
const permissions = requestPermissions(request, String(request.params.workId));
|
|
3044
|
+
if (!canWriteWorkModule(permissions, "ai-chat")) {
|
|
3045
|
+
throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有使用创作助手的权限");
|
|
3046
|
+
}
|
|
3047
|
+
return permissions;
|
|
3048
|
+
};
|
|
3049
|
+
app.post("/api/works/:workId/desktop-local-ai/runs", async (request, response) => {
|
|
3050
|
+
const input = parse(desktopLocalAiRunSchema, request.body);
|
|
3051
|
+
const permissions = assertDesktopLocalAiPermission(request);
|
|
3052
|
+
const citations = input.citations ?? [];
|
|
3053
|
+
for (const citation of citations) {
|
|
3054
|
+
if (store.getChapter(citation.chapterId).workId !== request.params.workId) {
|
|
3055
|
+
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
if (input.conversationId) {
|
|
3059
|
+
assertRequestAiConversationOwner(request, input.conversationId);
|
|
3060
|
+
const conversation = store.getAiConversationSummary(input.conversationId);
|
|
3061
|
+
if (String(conversation.workId) !== request.params.workId) {
|
|
3062
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
data(response, await ai.startDesktopLocalAiRun({
|
|
3066
|
+
workId: request.params.workId,
|
|
3067
|
+
taskType: input.taskType,
|
|
3068
|
+
instruction: instructionWithCitations(input.instruction, citations),
|
|
3069
|
+
scope: input.scope,
|
|
3070
|
+
runtimeModel: input.runtimeModel,
|
|
3071
|
+
...(input.conversationId ? { conversationId: input.conversationId } : {}),
|
|
3072
|
+
...(input.currentMessageId ? { excludeConversationMessageId: input.currentMessageId } : {}),
|
|
3073
|
+
...(input.imageAttachmentIds?.length ? { imageAttachmentIds: input.imageAttachmentIds } : {}),
|
|
3074
|
+
...(input.sceneDirection ? { sceneDirection: input.sceneDirection } : {})
|
|
3075
|
+
}, desktopLocalAiActorScope(request), currentRequestActor(), permissions), 202);
|
|
3076
|
+
});
|
|
3077
|
+
app.get("/api/works/:workId/desktop-local-ai/runs/:runId", (request, response) => {
|
|
3078
|
+
assertDesktopLocalAiPermission(request);
|
|
3079
|
+
const runId = parse(identifier, request.params.runId);
|
|
3080
|
+
data(response, ai.desktopLocalAiRunStatus(runId, request.params.workId, desktopLocalAiActorScope(request)));
|
|
3081
|
+
});
|
|
3082
|
+
app.post("/api/works/:workId/desktop-local-ai/runs/:runId/responses", (request, response) => {
|
|
3083
|
+
assertDesktopLocalAiPermission(request);
|
|
3084
|
+
const runId = parse(identifier, request.params.runId);
|
|
3085
|
+
const input = parse(desktopLocalAiCompletionResponseSchema, request.body);
|
|
3086
|
+
data(response, ai.submitDesktopLocalAiCompletion(runId, request.params.workId, desktopLocalAiActorScope(request), input));
|
|
3087
|
+
});
|
|
3088
|
+
app.delete("/api/works/:workId/desktop-local-ai/runs/:runId", (request, response) => {
|
|
3089
|
+
assertDesktopLocalAiPermission(request);
|
|
3090
|
+
const runId = parse(identifier, request.params.runId);
|
|
3091
|
+
data(response, ai.cancelDesktopLocalAiRun(runId, request.params.workId, desktopLocalAiActorScope(request)));
|
|
3092
|
+
});
|
|
2802
3093
|
app.post("/api/works/:workId/suggestions", async (request, response) => {
|
|
2803
3094
|
const input = parse(z.object({
|
|
2804
3095
|
taskType: z.enum(TASK_TYPES),
|
|
@@ -2829,7 +3120,13 @@ export function createRuntime(options) {
|
|
|
2829
3120
|
});
|
|
2830
3121
|
app.post("/api/works/:workId/chat/stream", async (request, response) => {
|
|
2831
3122
|
const input = parse(z.object({
|
|
2832
|
-
instruction:
|
|
3123
|
+
instruction: z.string().max(100_000).default(""),
|
|
3124
|
+
sceneDirection: z.string().max(20_000).optional(),
|
|
3125
|
+
scenePin: z.object({
|
|
3126
|
+
location: z.string().max(200).optional(),
|
|
3127
|
+
present: z.string().max(500).optional(),
|
|
3128
|
+
timeLabel: z.string().max(200).optional()
|
|
3129
|
+
}).strict().optional(),
|
|
2833
3130
|
scope: contextSchema,
|
|
2834
3131
|
modelId: identifier.optional(),
|
|
2835
3132
|
parameters: jsonObject.optional(),
|
|
@@ -2850,7 +3147,6 @@ export function createRuntime(options) {
|
|
|
2850
3147
|
if (store.getChapter(citation.chapterId).workId !== request.params.workId)
|
|
2851
3148
|
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
2852
3149
|
}
|
|
2853
|
-
const resolvedInstruction = instructionWithCitations(input.instruction, citations);
|
|
2854
3150
|
const existingRequest = store.findAiConversationStreamRequest(actorScope, request.params.workId, idempotencyKey);
|
|
2855
3151
|
if (existingRequest && input.conversationId && existingRequest.conversationId !== input.conversationId) {
|
|
2856
3152
|
throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该请求标识已用于另一项 AI 对话请求");
|
|
@@ -2865,6 +3161,20 @@ export function createRuntime(options) {
|
|
|
2865
3161
|
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
2866
3162
|
}
|
|
2867
3163
|
const conversationId = String(conversation.id);
|
|
3164
|
+
const isRoleplay = Boolean(conversation.roleplayCharacter);
|
|
3165
|
+
const instructionText = input.instruction.trim();
|
|
3166
|
+
const sceneDirection = isRoleplay ? (input.sceneDirection ?? "").trim() : "";
|
|
3167
|
+
if (!instructionText && !sceneDirection) {
|
|
3168
|
+
throw new AppError(400, "INSTRUCTION_REQUIRED", isRoleplay ? "请输入角色台词或场景旁白" : "请输入指令");
|
|
3169
|
+
}
|
|
3170
|
+
if (!existingRequest && isRoleplay && input.scenePin !== undefined) {
|
|
3171
|
+
store.setAiConversationScenePin(conversationId, request.params.workId, input.scenePin);
|
|
3172
|
+
}
|
|
3173
|
+
const storedUserContent = isRoleplay
|
|
3174
|
+
? composeRoleplayStoredUserContent(sceneDirection, instructionText)
|
|
3175
|
+
: instructionText;
|
|
3176
|
+
const resolvedInstruction = instructionWithCitations(instructionText, citations);
|
|
3177
|
+
const mentionSource = [sceneDirection, resolvedInstruction].filter(Boolean).join("\n");
|
|
2868
3178
|
const modelId = resolveConversationModelId(request.params.workId, conversationId, input.modelId);
|
|
2869
3179
|
const permissions = requestPermissions(request, request.params.workId);
|
|
2870
3180
|
const controller = new AbortController();
|
|
@@ -2905,7 +3215,7 @@ export function createRuntime(options) {
|
|
|
2905
3215
|
workId: request.params.workId,
|
|
2906
3216
|
modelId,
|
|
2907
3217
|
scope: input.scope,
|
|
2908
|
-
instruction:
|
|
3218
|
+
instruction: mentionSource,
|
|
2909
3219
|
excludeConversationMessageId: input.currentMessageId
|
|
2910
3220
|
}, { ignoreWarning: input.ignoreContextWarning === true });
|
|
2911
3221
|
preparedConversation = redactAiConversation(store.getAiConversationSummary(conversationId), permissions);
|
|
@@ -2930,7 +3240,7 @@ export function createRuntime(options) {
|
|
|
2930
3240
|
: ai.resolveInstructionMentions({
|
|
2931
3241
|
workId: request.params.workId,
|
|
2932
3242
|
taskType: "chat",
|
|
2933
|
-
instruction:
|
|
3243
|
+
instruction: mentionSource,
|
|
2934
3244
|
scope: input.scope,
|
|
2935
3245
|
conversationId
|
|
2936
3246
|
});
|
|
@@ -2947,7 +3257,7 @@ export function createRuntime(options) {
|
|
|
2947
3257
|
idempotencyKey,
|
|
2948
3258
|
requestHash,
|
|
2949
3259
|
userMessage: {
|
|
2950
|
-
content:
|
|
3260
|
+
content: storedUserContent,
|
|
2951
3261
|
citations,
|
|
2952
3262
|
...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
|
|
2953
3263
|
...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
|
|
@@ -3005,6 +3315,7 @@ export function createRuntime(options) {
|
|
|
3005
3315
|
const suggestion = await ai.createStreamingChat({
|
|
3006
3316
|
workId: request.params.workId,
|
|
3007
3317
|
instruction: resolvedInstruction,
|
|
3318
|
+
...(sceneDirection ? { sceneDirection } : {}),
|
|
3008
3319
|
// 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
|
|
3009
3320
|
scope: input.scope,
|
|
3010
3321
|
signal: controller.signal,
|
|
@@ -3310,6 +3621,7 @@ export function createRuntime(options) {
|
|
|
3310
3621
|
backups.dispose();
|
|
3311
3622
|
liteLlmPriceCache.dispose();
|
|
3312
3623
|
ai.dispose();
|
|
3624
|
+
offlineSync.dispose();
|
|
3313
3625
|
const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
|
|
3314
3626
|
if (cancelledStreamRequests > 0)
|
|
3315
3627
|
logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
|
|
@@ -3335,6 +3647,6 @@ export function createRuntime(options) {
|
|
|
3335
3647
|
})();
|
|
3336
3648
|
return closePromise;
|
|
3337
3649
|
};
|
|
3338
|
-
return { app, database, store, ai, liteLlmPriceCache, backups, auth, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
|
|
3650
|
+
return { app, database, store, ai, liteLlmPriceCache, backups, auth, offlineSync, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
|
|
3339
3651
|
}
|
|
3340
3652
|
//# sourceMappingURL=app.js.map
|