@musnows/scriverse 0.6.0 → 0.6.2

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.
Files changed (44) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +177 -0
  4. package/dist/ai-tool-results.js.map +1 -0
  5. package/dist/ai.js +516 -138
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +186 -55
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +9 -5
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +14 -2
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/collaboration-presence.js +1 -0
  16. package/dist/collaboration-presence.js.map +1 -1
  17. package/dist/credential-vault.js +7 -5
  18. package/dist/credential-vault.js.map +1 -1
  19. package/dist/database.js +121 -2
  20. package/dist/database.js.map +1 -1
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/public/ai-context-meter.js +7 -0
  24. package/dist/public/app.js +556 -223
  25. package/dist/public/entity-version.js +2 -2
  26. package/dist/public/index.html +19 -11
  27. package/dist/public/markdown.js +1 -2
  28. package/dist/public/model-config.d.ts +3 -0
  29. package/dist/public/model-config.js +14 -0
  30. package/dist/public/page-route.js +1 -0
  31. package/dist/public/styles.css +40 -20
  32. package/dist/public/work-permissions.d.ts +1 -1
  33. package/dist/public/work-permissions.js +1 -1
  34. package/dist/security.js +45 -10
  35. package/dist/security.js.map +1 -1
  36. package/dist/server-runtime.js +56 -1
  37. package/dist/server-runtime.js.map +1 -1
  38. package/dist/store.js +236 -25
  39. package/dist/store.js.map +1 -1
  40. package/dist/user-auth.js +19 -71
  41. package/dist/user-auth.js.map +1 -1
  42. package/dist/version.js +1 -1
  43. package/dist/work-permissions.js +1 -1
  44. package/package.json +1 -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, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, 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"],
@@ -103,7 +105,7 @@ const presenceHeartbeatSchema = z.object({
103
105
  page: z.discriminatedUnion("kind", [
104
106
  z.object({ kind: z.literal(presencePageKinds[0]) }).strict(),
105
107
  z.object({ kind: z.literal(presencePageKinds[1]), resourceId: identifier }).strict(),
106
- z.object({ kind: z.literal(presencePageKinds[2]), module: z.enum(["drafts", "settings", "characters", "races", "organizations", "timeline", "relationships", "outlines", "reviews", "tasks", "ai-settings"]) }).strict(),
108
+ z.object({ kind: z.literal(presencePageKinds[2]), module: z.enum(["drafts", "settings", "characters", "races", "organizations", "timeline", "comments", "relationships", "outlines", "reviews", "tasks", "ai-settings"]) }).strict(),
107
109
  z.object({ kind: z.literal(presencePageKinds[3]), module: z.enum(["setting", "character", "race", "organization", "relationship"]), resourceId: identifier.optional() }).strict(),
108
110
  z.object({ kind: z.literal(presencePageKinds[4]) }).strict()
109
111
  ])
@@ -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();
@@ -300,7 +304,7 @@ const modelSchema = z.object({
300
304
  modelId: nonEmpty.max(300),
301
305
  purposes: optionalStrings,
302
306
  contextNote: z.string().max(10_000).optional(),
303
- contextWindow: z.number().int().min(1_024).max(2_000_000).optional(),
307
+ contextWindow: z.number().int().min(32_768, "模型上下文不能低于 32768 Token").max(2_000_000).optional(),
304
308
  outputNote: z.string().max(10_000).optional(),
305
309
  preset: jsonObject.optional(),
306
310
  thinkingEnabled: z.boolean().optional(),
@@ -322,6 +326,7 @@ const platformPageSizesSchema = z.object({
322
326
  timeline: z.number().int().min(10).max(100).optional(),
323
327
  outlines: z.number().int().min(10).max(100).optional(),
324
328
  relationships: z.number().int().min(10).max(100).optional(),
329
+ comments: z.number().int().min(10).max(100).optional(),
325
330
  reviews: z.number().int().min(10).max(100).optional(),
326
331
  analysisTasks: z.number().int().min(10).max(100).optional(),
327
332
  fileVersions: z.number().int().min(10).max(100).optional()
@@ -361,6 +366,15 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
361
366
  round: z.number().int().min(1).max(20),
362
367
  toolCall: aiToolCallResultSchema,
363
368
  createdAt: z.string().datetime({ offset: true })
369
+ }).strict(),
370
+ z.object({
371
+ id: z.string().min(1).max(300),
372
+ type: z.literal("context_compaction"),
373
+ round: z.number().int().min(1).max(20),
374
+ sourceMessageCount: z.number().int().min(1).max(100),
375
+ sourceChars: z.number().int().min(1).max(10_000_000),
376
+ summaryChars: z.number().int().min(1).max(1_000_000),
377
+ createdAt: z.string().datetime({ offset: true })
364
378
  }).strict()
365
379
  ]);
366
380
  const workAiSettingsSchema = z.object({
@@ -586,6 +600,32 @@ function redactTaskCharacterNames(record, permissions) {
586
600
  }
587
601
  return result;
588
602
  }
603
+ function redactAiCallContext(record, permissions) {
604
+ const result = { ...record };
605
+ const scope = recordValue(result.contextScope);
606
+ if (!scope)
607
+ return result;
608
+ const redactedScope = { ...scope };
609
+ let restricted = false;
610
+ if (permissions.prose === "none") {
611
+ for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
612
+ if (field in redactedScope) {
613
+ delete redactedScope[field];
614
+ restricted = true;
615
+ }
616
+ }
617
+ }
618
+ if (permissions.characters === "none" && "characterIds" in redactedScope) {
619
+ delete redactedScope.characterIds;
620
+ restricted = true;
621
+ }
622
+ if (permissions.settings === "none" && "settingIds" in redactedScope) {
623
+ delete redactedScope.settingIds;
624
+ restricted = true;
625
+ }
626
+ result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
627
+ return result;
628
+ }
589
629
  function redactMergeRecords(value, mapper) {
590
630
  const record = recordValue(value);
591
631
  if (!record)
@@ -632,6 +672,32 @@ export function createRuntime(options) {
632
672
  ? auth.listUsers().find((user) => user.status === "active") ?? null
633
673
  : null;
634
674
  const store = new Store(database);
675
+ let attachmentCleanupChain = Promise.resolve();
676
+ const cleanupAttachments = () => {
677
+ const cleanup = attachmentCleanupChain.then(async () => {
678
+ store.queueUnreferencedAttachments();
679
+ for (const queued of store.listAttachmentCleanupQueue()) {
680
+ if (!store.attachmentCleanupStillRequired(queued.storageKey)) {
681
+ store.completeAttachmentCleanup(queued.storageKey);
682
+ continue;
683
+ }
684
+ try {
685
+ await attachmentStorage.remove(queued.storageKey);
686
+ store.completeAttachmentCleanup(queued.storageKey);
687
+ }
688
+ catch (error) {
689
+ store.failAttachmentCleanup(queued.storageKey, error instanceof Error ? error.message : "Attachment cleanup failed");
690
+ logger.warn("attachment.cleanup.failed", {
691
+ storageKey: queued.storageKey,
692
+ attempts: queued.attempts + 1,
693
+ error: sanitizeError(error)
694
+ });
695
+ }
696
+ }
697
+ });
698
+ attachmentCleanupChain = cleanup.catch(() => undefined);
699
+ return cleanup;
700
+ };
635
701
  const requestPermissions = (request, workId) => {
636
702
  if (!request.authUser)
637
703
  return fullWorkModulePermissions();
@@ -699,14 +765,16 @@ export function createRuntime(options) {
699
765
  app.get("/api/auth/session", (request, response) => {
700
766
  const session = auth.authenticate(request);
701
767
  const registrationOpen = options.security?.allowRegistration === true;
768
+ const setupRequired = !auth.hasUsers();
769
+ const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
702
770
  const developmentUser = getDevelopmentUser();
703
771
  if (!session && developmentUser) {
704
- data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, registrationOpen });
772
+ data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, setupTokenRequired: false, registrationOpen });
705
773
  return;
706
774
  }
707
775
  data(response, session
708
- ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, registrationOpen }
709
- : { authenticated: false, user: null, csrfToken: null, setupRequired: !auth.hasUsers(), registrationOpen });
776
+ ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, setupTokenRequired: false, registrationOpen }
777
+ : { authenticated: false, user: null, csrfToken: null, setupRequired, setupTokenRequired, registrationOpen });
710
778
  });
711
779
  app.get("/api/auth/captcha", (_request, response) => {
712
780
  data(response, captcha.create());
@@ -717,6 +785,9 @@ export function createRuntime(options) {
717
785
  }
718
786
  const input = parse(registrationSchema, request.body);
719
787
  captcha.consume(input.captchaId, input.captchaAnswer);
788
+ if (!auth.hasUsers() && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
789
+ throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
790
+ }
720
791
  const result = auth.register({ username: input.username, password: input.password });
721
792
  setSessionCookie(response, result.token, request.secure);
722
793
  runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
@@ -736,6 +807,7 @@ export function createRuntime(options) {
736
807
  disabled: options.disableUserAuth === true,
737
808
  resolveBypassUser: getDevelopmentUser
738
809
  }));
810
+ app.use(createUploadRateLimitMiddleware());
739
811
  app.use(createCliApiScopeMiddleware(options.disableUserAuth));
740
812
  app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
741
813
  app.get("/api/cli/session", (request, response) => {
@@ -935,8 +1007,8 @@ export function createRuntime(options) {
935
1007
  });
936
1008
  app.delete("/api/works/:workId", async (request, response) => {
937
1009
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
938
- const removableStorageKeys = store.deleteWork(request.params.workId, input.expectedVersionNo);
939
- await Promise.all(removableStorageKeys.map((storageKey) => attachmentStorage.remove(storageKey)));
1010
+ store.deleteWork(request.params.workId, input.expectedVersionNo);
1011
+ await cleanupAttachments();
940
1012
  noContent(response);
941
1013
  });
942
1014
  app.get("/api/works/:workId/cover", (request, response) => {
@@ -951,14 +1023,16 @@ export function createRuntime(options) {
951
1023
  if (!request.file)
952
1024
  throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
953
1025
  const bytes = request.file.buffer;
954
- const isPng = bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
955
- const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
956
- const isWebp = bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
957
- const mimeType = isPng ? "image/png" : isJpeg ? "image/jpeg" : isWebp ? "image/webp" : null;
958
- if (!mimeType)
959
- throw new AppError(415, "INVALID_COVER", "封面文件内容不是有效的 PNG、JPEG 或 WebP 图片");
960
- const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
961
- data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes, expectedVersionNo));
1026
+ try {
1027
+ const metadata = readRasterImageMetadata(bytes);
1028
+ const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
1029
+ data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
1030
+ }
1031
+ catch (error) {
1032
+ if (error instanceof InvalidRasterImageError)
1033
+ throw new AppError(415, "INVALID_COVER", error.message);
1034
+ throw error;
1035
+ }
962
1036
  });
963
1037
  app.delete("/api/works/:workId/cover", (request, response) => {
964
1038
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
@@ -1049,6 +1123,12 @@ export function createRuntime(options) {
1049
1123
  data(response, pagination ? store.listChapterInsightsPage(request.params.chapterId, pagination) : store.listChapterInsights(request.params.chapterId));
1050
1124
  });
1051
1125
  app.get("/api/chapters/:chapterId/annotations", (request, response) => data(response, store.listChapterAnnotations(request.params.chapterId)));
1126
+ app.get("/api/works/:workId/chapter-annotations", (request, response) => {
1127
+ const pagination = parsePagination(request.query);
1128
+ data(response, pagination
1129
+ ? store.listWorkChapterAnnotationsPage(request.params.workId, pagination)
1130
+ : store.listWorkChapterAnnotations(request.params.workId));
1131
+ });
1052
1132
  app.post("/api/chapters/:chapterId/annotations", (request, response) => {
1053
1133
  const input = parse(z.object({
1054
1134
  kind: z.enum(["note", "todo"]),
@@ -1285,11 +1365,16 @@ export function createRuntime(options) {
1285
1365
  });
1286
1366
  app.get("/api/works/:workId/attachments", (request, response) => {
1287
1367
  const pagination = parsePagination(request.query);
1288
- data(response, pagination ? store.listAttachmentsPage(request.params.workId, pagination) : store.listAttachments(request.params.workId));
1368
+ const permissions = requestPermissions(request, request.params.workId);
1369
+ const readable = store.listAttachments(request.params.workId).filter((attachment) => (store.attachmentModules(String(attachment.id)).some((module) => canReadWorkModule(permissions, module))));
1370
+ data(response, pagination
1371
+ ? paginated(readable.slice(pagination.offset, pagination.offset + pagination.limit + 1), pagination, readable.length)
1372
+ : readable);
1289
1373
  });
1290
1374
  app.post("/api/works/:workId/attachments", attachmentUpload.single("file"), async (request, response) => {
1291
1375
  if (!request.file)
1292
1376
  throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
1377
+ const accessModule = parse(attachmentPermissionModuleSchema, request.query.module ?? "settings");
1293
1378
  let storageKey = null;
1294
1379
  try {
1295
1380
  const stored = await attachmentStorage.ingest(request.file.path);
@@ -1297,7 +1382,7 @@ export function createRuntime(options) {
1297
1382
  const result = store.createAttachment(String(request.params.workId), {
1298
1383
  originalName: normalizeUploadFileName(request.file.originalname),
1299
1384
  ...stored
1300
- });
1385
+ }, accessModule);
1301
1386
  data(response, { ...result.attachment, deduplicated: !result.created }, result.created ? 201 : 200);
1302
1387
  }
1303
1388
  catch (error) {
@@ -1314,6 +1399,10 @@ export function createRuntime(options) {
1314
1399
  });
1315
1400
  app.get("/api/attachments/:attachmentId/content", async (request, response) => {
1316
1401
  const attachment = store.getAttachment(request.params.attachmentId);
1402
+ const permissions = requestPermissions(request, String(attachment.workId));
1403
+ if (!store.attachmentModules(request.params.attachmentId).some((module) => canReadWorkModule(permissions, module))) {
1404
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
1405
+ }
1317
1406
  const content = await attachmentStorage.read(String(attachment.storageKey));
1318
1407
  response.setHeader("Content-Type", String(attachment.storedMimeType));
1319
1408
  response.setHeader("Content-Length", String(attachment.storedByteLength));
@@ -1323,9 +1412,13 @@ export function createRuntime(options) {
1323
1412
  response.send(content);
1324
1413
  });
1325
1414
  app.delete("/api/attachments/:attachmentId", async (request, response) => {
1326
- const deleted = store.deleteAttachment(request.params.attachmentId);
1327
- if (deleted.removeStoredFile)
1328
- await attachmentStorage.remove(deleted.storageKey);
1415
+ const attachment = store.getAttachment(request.params.attachmentId);
1416
+ const permissions = requestPermissions(request, String(attachment.workId));
1417
+ if (!store.attachmentModules(request.params.attachmentId).some((module) => canWriteWorkModule(permissions, module))) {
1418
+ throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有编辑该附件所属资料模块的权限");
1419
+ }
1420
+ store.deleteAttachment(request.params.attachmentId);
1421
+ await cleanupAttachments();
1329
1422
  noContent(response);
1330
1423
  });
1331
1424
  app.get("/api/works/:workId/races", (request, response) => {
@@ -1683,8 +1776,11 @@ export function createRuntime(options) {
1683
1776
  data(response, updated);
1684
1777
  });
1685
1778
  app.get("/api/works/:workId/ai-conversations", (request, response) => {
1686
- const pagination = parsePagination(request.query);
1687
- data(response, pagination ? store.listAiConversationsPage(request.params.workId, pagination) : store.listAiConversations(request.params.workId));
1779
+ const pagination = parsePagination({
1780
+ page: request.query.page ?? "1",
1781
+ limit: request.query.limit ?? "20"
1782
+ }) ?? { page: 1, limit: 20, offset: 0 };
1783
+ data(response, store.listAiConversationsPage(request.params.workId, pagination));
1688
1784
  });
1689
1785
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
1690
1786
  const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
@@ -1734,32 +1830,23 @@ export function createRuntime(options) {
1734
1830
  app.post("/api/ai-conversations/:conversationId/compact", async (request, response) => {
1735
1831
  const input = parse(z.object({ modelId: identifier.optional(), scope: contextSchema }), request.body);
1736
1832
  const conversation = store.getAiConversation(request.params.conversationId);
1737
- data(response, await ai.compactConversation({
1833
+ const compacted = await ai.compactConversation({
1738
1834
  conversationId: request.params.conversationId,
1739
1835
  workId: String(conversation.workId),
1740
1836
  modelId: input.modelId,
1741
1837
  scope: input.scope
1742
- }));
1743
- });
1744
- app.post("/api/works/:workId/ai-context-usage", (request, response) => {
1745
- const input = parse(z.object({
1746
- modelId: identifier.optional(),
1747
- taskType: z.enum(TASK_TYPES).default("chat"),
1748
- scope: contextSchema,
1749
- instruction: z.string().max(100_000).default(""),
1750
- citations: aiCitationsSchema.optional(),
1751
- conversationId: identifier.optional(),
1752
- currentMessageId: identifier.optional()
1753
- }), request.body ?? {});
1754
- data(response, ai.getContextUsage({
1755
- workId: request.params.workId,
1756
- modelId: input.modelId,
1757
- taskType: input.taskType,
1758
- scope: input.scope,
1759
- instruction: instructionWithCitations(input.instruction, input.citations ?? []),
1760
- conversationId: input.conversationId,
1761
- excludeConversationMessageId: input.currentMessageId
1762
- }));
1838
+ });
1839
+ data(response, {
1840
+ ...compacted,
1841
+ usage: ai.getContextUsage({
1842
+ workId: String(conversation.workId),
1843
+ taskType: "chat",
1844
+ instruction: "",
1845
+ conversationId: request.params.conversationId,
1846
+ modelId: input.modelId,
1847
+ scope: input.scope
1848
+ })
1849
+ });
1763
1850
  });
1764
1851
  app.get("/api/works/:workId/providers", (request, response) => {
1765
1852
  store.getWork(request.params.workId);
@@ -1861,6 +1948,40 @@ export function createRuntime(options) {
1861
1948
  };
1862
1949
  sendEvent("ready", { streaming: true });
1863
1950
  try {
1951
+ const conversation = input.conversationId
1952
+ ? store.getAiConversationSummary(input.conversationId)
1953
+ : store.createAiConversation(request.params.workId);
1954
+ if (String(conversation.workId) !== request.params.workId) {
1955
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
1956
+ }
1957
+ const conversationId = String(conversation.id);
1958
+ const prepared = await ai.prepareConversationContext({
1959
+ conversationId,
1960
+ workId: request.params.workId,
1961
+ modelId: input.modelId,
1962
+ scope: input.scope,
1963
+ instruction: instructionWithCitations(input.instruction, citations),
1964
+ excludeConversationMessageId: input.currentMessageId
1965
+ });
1966
+ sendEvent("context", {
1967
+ ...prepared,
1968
+ conversation: {
1969
+ ...store.getAiConversationSummary(conversationId),
1970
+ contextWarningPending: prepared.action === "warn"
1971
+ }
1972
+ });
1973
+ if (prepared.action === "warn")
1974
+ return;
1975
+ const userMessage = input.currentMessageId
1976
+ ? null
1977
+ : store.addAiConversationMessage(conversationId, {
1978
+ role: "user",
1979
+ content: input.instruction,
1980
+ citations
1981
+ });
1982
+ const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
1983
+ if (userMessage)
1984
+ sendEvent("user_message", { message: userMessage });
1864
1985
  const suggestion = await ai.createStreamingChat({
1865
1986
  workId: request.params.workId,
1866
1987
  instruction: instructionWithCitations(input.instruction, citations),
@@ -1868,9 +1989,10 @@ export function createRuntime(options) {
1868
1989
  signal: controller.signal,
1869
1990
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
1870
1991
  onProcessStep: (step) => sendEvent("process_step", step),
1871
- conversationId: input.conversationId,
1872
- excludeConversationMessageId: input.currentMessageId,
1873
- ...(input.currentMessageId ? { assistantMessageRequestId: `assistant:${input.currentMessageId}` } : {}),
1992
+ onContextCompacted: (event) => sendEvent("context_compacted", event),
1993
+ conversationId,
1994
+ excludeConversationMessageId: currentMessageId,
1995
+ ...(currentMessageId ? { assistantMessageRequestId: `assistant:${currentMessageId}` } : {}),
1874
1996
  ...(input.modelId ? { modelId: input.modelId } : {}),
1875
1997
  ...(input.parameters ? { parameters: input.parameters } : {})
1876
1998
  }, (delta) => sendEvent("delta", { delta }));
@@ -1884,6 +2006,8 @@ export function createRuntime(options) {
1884
2006
  chapterVersion: suggestion.chapterVersion,
1885
2007
  toolCalls: suggestion.toolCalls,
1886
2008
  processSteps: suggestion.processSteps,
2009
+ contextUsage: suggestion.contextUsage,
2010
+ conversationId,
1887
2011
  conversationTitle: suggestion.conversationTitle,
1888
2012
  messageId: typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
1889
2013
  ? suggestion.conversationMessage.id
@@ -1903,7 +2027,11 @@ export function createRuntime(options) {
1903
2027
  message: error instanceof Error ? error.message : "AI 流式调用失败",
1904
2028
  ...(error instanceof AppError ? { status: error.status } : {}),
1905
2029
  ...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
1906
- ...(typeof details?.callId === "string" ? { callId: details.callId } : {})
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 } : {})
1907
2035
  });
1908
2036
  }
1909
2037
  }
@@ -1930,7 +2058,10 @@ export function createRuntime(options) {
1930
2058
  app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
1931
2059
  app.get("/api/works/:workId/ai-calls", (request, response) => {
1932
2060
  const pagination = parsePagination(request.query);
1933
- data(response, pagination ? ai.listCallsPage(request.params.workId, pagination) : ai.listCalls(request.params.workId));
2061
+ const permissions = requestPermissions(request, request.params.workId);
2062
+ data(response, pagination
2063
+ ? mapRecords(ai.listCallsPage(request.params.workId, pagination), (call) => redactAiCallContext(call, permissions))
2064
+ : ai.listCalls(request.params.workId).map((call) => redactAiCallContext(call, permissions)));
1934
2065
  });
1935
2066
  app.get("/api/works/:workId/search", async (request, response) => {
1936
2067
  const query = parse(z.object({
@@ -2084,7 +2215,7 @@ export function createRuntime(options) {
2084
2215
  response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
2085
2216
  });
2086
2217
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2087
- return { app, database, store, ai, auth, attachmentStorage, close: () => {
2218
+ return { app, database, store, ai, auth, attachmentStorage, cleanupAttachments, close: () => {
2088
2219
  logger.info("runtime.closing");
2089
2220
  ai.dispose();
2090
2221
  database.close();