@modesto_ma/dsh-channels 0.6.0 → 0.8.0-beta.1

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { constants, createWriteStream, promises, readFileSync } from "node:fs";
4
4
  import { access, mkdir, open, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -180,12 +180,12 @@ var AccessController = class {
180
180
  }, this.config(), this.deps.store.snapshot()).kind === "allow";
181
181
  }
182
182
  /**
183
- * Whether the analyze API may post into `chatId`: an authorized group, or
184
- * any group in `open` access mode.
183
+ * Whether a `feishuTopics` launch may post into `chatId`: an authorized
184
+ * group, or any group in `open` access mode.
185
185
  * @param chatId - target group chat id.
186
186
  * @returns true when the chat is a permitted target.
187
187
  */
188
- mayAnalyzeIn(chatId) {
188
+ mayLaunchIn(chatId) {
189
189
  if (this.config().accessMode === "open") return true;
190
190
  return this.deps.store.snapshot().groups.has(chatId);
191
191
  }
@@ -1114,7 +1114,7 @@ const routeRecordSchema = z.object({
1114
1114
  /** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
1115
1115
  sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
1116
1116
  /**
1117
- * Group-shared route (log-platform analysis topics): every member admitted
1117
+ * Group-shared route (topics started through `feishuTopics`): every member admitted
1118
1118
  * in `chatId` may continue the Session and decide its cards, not only
1119
1119
  * `senderUserId` (who then names the requester). Absent = owner-only.
1120
1120
  */
@@ -1305,36 +1305,67 @@ const processedRecordSchema = z.object({
1305
1305
  /** Epoch ms when the message was accepted for processing. */
1306
1306
  processedAt: z.number().int().nonnegative()
1307
1307
  });
1308
+ /** {@link TopicRootCard} as stored in a launch record. */
1309
+ const topicRootCardSchema = z.object({
1310
+ title: z.string(),
1311
+ taskName: z.string(),
1312
+ lead: z.array(z.object({
1313
+ text: z.string(),
1314
+ bold: z.boolean().optional()
1315
+ })),
1316
+ links: z.object({
1317
+ nameHeader: z.string(),
1318
+ linkHeader: z.string(),
1319
+ rows: z.array(z.object({
1320
+ name: z.string(),
1321
+ url: z.string()
1322
+ }))
1323
+ }).optional()
1324
+ });
1308
1325
  /**
1309
- * One accepted analyze API request, keyed by its `Idempotency-Key`. Kept
1310
- * forever: a retried key returns the stored response instead of posting a
1311
- * second analysis, and a reused key with a different body is refused.
1326
+ * Root card content written by plugin versions before the `feishuTopics`
1327
+ * service (the built-in log-platform analyze API). Read only; converted by
1328
+ * {@link rootCardOf}.
1312
1329
  */
1313
- const analyzeRequestRecordSchema = z.object({
1314
- /** The request's `Idempotency-Key` (also the table key). */
1330
+ const legacyAnalyzeSubjectSchema = z.object({
1331
+ requesterUserId: z.string(),
1332
+ triggeredAt: z.string(),
1333
+ app: z.string(),
1334
+ errorCode: z.string(),
1335
+ logDetails: z.array(z.object({
1336
+ name: z.string(),
1337
+ url: z.string()
1338
+ }))
1339
+ });
1340
+ /**
1341
+ * One accepted `feishuTopics` launch, keyed by its idempotency key. Kept
1342
+ * forever: a retried key answers the stored root card instead of posting a
1343
+ * second topic, and a reused key with another fingerprint is refused. The
1344
+ * records live in the `analyze_requests` table, named after the analyze API
1345
+ * that wrote the first of them; those older records carry `subject` instead
1346
+ * of `requesterUserId` + `card`, and their keys are the analyze API's
1347
+ * `Idempotency-Key`s.
1348
+ */
1349
+ const topicLaunchRecordSchema = z.object({
1350
+ /** The launch's idempotency key (also the table key). */
1315
1351
  key: z.string().min(1),
1316
- /** SHA-256 (hex) of the canonical request body. */
1352
+ /** The launch's fingerprint (older records: SHA-256 of the analyze request). */
1317
1353
  bodyHash: z.string().min(1),
1318
1354
  /** Target group chat. */
1319
1355
  chatId: z.string().min(1),
1320
- /** Root card message id (the analysis topic's root). */
1356
+ /** Root card message id (the topic's root). */
1321
1357
  messageId: z.string().min(1),
1322
- /** Epoch ms when the request was accepted. */
1358
+ /** Epoch ms when the launch was accepted. */
1323
1359
  acceptedAt: z.number().int().nonnegative(),
1324
- /** What the root card shows, kept so the card can be redrawn after a restart. */
1325
- subject: z.object({
1326
- requesterUserId: z.string(),
1327
- triggeredAt: z.string(),
1328
- app: z.string(),
1329
- errorCode: z.string(),
1330
- logDetails: z.array(z.object({
1331
- name: z.string(),
1332
- url: z.string()
1333
- }))
1334
- }),
1360
+ /** Requester @-mentioned on the root card; absent on older records. */
1361
+ requesterUserId: z.string().optional(),
1362
+ /** What the root card shows, kept so the card can be redrawn after a restart; absent on older records. */
1363
+ card: topicRootCardSchema.optional(),
1364
+ /** Root card content of older records. */
1365
+ subject: legacyAnalyzeSubjectSchema.optional(),
1335
1366
  /**
1336
1367
  * Root card status. `running` until the first turn settles; a record still
1337
- * `running` when the channel starts belongs to an analysis the previous
1368
+ * `running` when the channel starts belongs to a topic the previous
1338
1369
  * instance never finished, and becomes `interrupted`.
1339
1370
  */
1340
1371
  status: z.enum([
@@ -1347,9 +1378,48 @@ const analyzeRequestRecordSchema = z.object({
1347
1378
  ])
1348
1379
  });
1349
1380
  /**
1381
+ * The requester and root card content of one launch record, converting the
1382
+ * analyze API's older records to the card that API drew.
1383
+ * @param record - stored launch record.
1384
+ * @returns what to redraw, or undefined for a record carrying neither form.
1385
+ */
1386
+ function rootCardOf(record) {
1387
+ if (record.card !== void 0 && record.requesterUserId !== void 0) return {
1388
+ requesterUserId: record.requesterUserId,
1389
+ card: record.card
1390
+ };
1391
+ const subject = record.subject;
1392
+ if (subject === void 0) return void 0;
1393
+ return {
1394
+ requesterUserId: subject.requesterUserId,
1395
+ card: {
1396
+ title: "日志分析",
1397
+ taskName: "分析",
1398
+ lead: [
1399
+ { text: `于 ${subject.triggeredAt} 发起了对 ` },
1400
+ {
1401
+ text: subject.app,
1402
+ bold: true
1403
+ },
1404
+ { text: " 应用 " },
1405
+ {
1406
+ text: subject.errorCode,
1407
+ bold: true
1408
+ },
1409
+ { text: " 错误码不良日志的分析。" }
1410
+ ],
1411
+ links: {
1412
+ nameHeader: "日志",
1413
+ linkHeader: "详情页",
1414
+ rows: subject.logDetails
1415
+ }
1416
+ }
1417
+ };
1418
+ }
1419
+ /**
1350
1420
  * Where one Feishu-routed Session came from, keyed by its DSH session id.
1351
1421
  * Written the first time a route record names the Session (new topic,
1352
- * `/renew`, analyze API) and kept after the route retires, so the Web
1422
+ * `/renew`, `feishuTopics` launch) and kept after the route retires, so the Web
1353
1423
  * client can still show the origin of an old Session. Names are resolved
1354
1424
  * live when read, never stored.
1355
1425
  */
@@ -1364,11 +1434,11 @@ const sessionOriginRecordSchema = z.object({
1364
1434
  chatId: z.string().min(1),
1365
1435
  /** `p2p` or `group`. */
1366
1436
  chatType: z.enum(["p2p", "group"]),
1367
- /** `user_id` of the user who started the Session (the requester for analyze topics). */
1437
+ /** `user_id` of the user who started the Session (the requester for launched topics). */
1368
1438
  senderUserId: z.string().min(1),
1369
1439
  /** Epoch ms when the Session was bound to the route. */
1370
1440
  createdAt: z.number().int().nonnegative(),
1371
- /** Group-shared route (log-platform analysis topic). */
1441
+ /** Group-shared route (topic started through `feishuTopics`). */
1372
1442
  shared: z.literal(true).optional()
1373
1443
  });
1374
1444
  /**
@@ -1399,7 +1469,7 @@ const feishuDomain = {
1399
1469
  access_users: { valueSchema: accessUserRecordSchema },
1400
1470
  access_requests: { valueSchema: accessRequestRecordSchema },
1401
1471
  access_wiki_users: { valueSchema: accessUserRecordSchema },
1402
- analyze_requests: { valueSchema: analyzeRequestRecordSchema },
1472
+ analyze_requests: { valueSchema: topicLaunchRecordSchema },
1403
1473
  session_origins: { valueSchema: sessionOriginRecordSchema }
1404
1474
  }
1405
1475
  };
@@ -2833,18 +2903,10 @@ function parsePickAction(value) {
2833
2903
  };
2834
2904
  }
2835
2905
  //#endregion
2836
- //#region src/cards/analyze.ts
2837
- /**
2838
- * Root card of a log-platform analysis: who started which analysis when, the
2839
- * log-platform detail pages behind it, and a status tag (分析中 → 已完成 /
2840
- * 已停止 / 失败 / 已中断). The analysis itself streams in the topic below the
2841
- * card through the ordinary streaming card.
2842
- *
2843
- * @module dsh-channels/cards/analyze
2844
- */
2906
+ //#region src/cards/topic.ts
2845
2907
  /** Rows per table page (Feishu caps `page_size` at 10). */
2846
2908
  const TABLE_PAGE_SIZE = 10;
2847
- /** Escape markdown emphasis characters in platform-supplied text. */
2909
+ /** Escape markdown emphasis characters in caller-supplied text. */
2848
2910
  function plain$1(text) {
2849
2911
  return text.replace(/([*_~`[\]\\])/gu, "\\$1");
2850
2912
  }
@@ -2853,10 +2915,10 @@ function linkTarget(url) {
2853
2915
  return url.replace(/[()\s]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
2854
2916
  }
2855
2917
  /** Header tag text, tag color, and header template of each status. */
2856
- function statusLook(status) {
2918
+ function statusLook(status, task) {
2857
2919
  switch (status.kind) {
2858
2920
  case "running": return {
2859
- tag: "分析中",
2921
+ tag: `${task}中`,
2860
2922
  color: "blue",
2861
2923
  template: "blue"
2862
2924
  };
@@ -2884,31 +2946,60 @@ function statusLook(status) {
2884
2946
  }
2885
2947
  }
2886
2948
  /** Footer line of each status. */
2887
- function statusNote(status) {
2949
+ function statusNote(status, task) {
2888
2950
  switch (status.kind) {
2889
- case "running": return "分析过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。";
2890
- case "done": return "分析已完成,结果见话题;在话题内回复即可继续追问。";
2891
- case "stopped": return "分析已停止;在话题内回复即可继续。";
2892
- case "error": return "分析回合失败,原因见话题;在话题内回复即可重试或追问。";
2893
- case "failed": return `分析未能开始:${plain$1(status.reason)}`;
2894
- case "interrupted": return "DSH 重启导致分析中断;在话题内回复即可继续。";
2951
+ case "running": return `${task}过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。`;
2952
+ case "done": return `${task}已完成,结果见话题;在话题内回复即可继续追问。`;
2953
+ case "stopped": return `${task}已停止;在话题内回复即可继续。`;
2954
+ case "error": return `${task}回合失败,原因见话题;在话题内回复即可重试或追问。`;
2955
+ case "failed": return `${task}未能开始:${plain$1(status.reason)}`;
2956
+ case "interrupted": return `DSH 重启导致${task}中断;在话题内回复即可继续。`;
2895
2957
  }
2896
2958
  }
2897
2959
  /**
2898
- * The analysis root card.
2899
- * @param subject - the analysis subject.
2960
+ * The topic root card.
2961
+ * @param requesterUserId - `user_id` @-mentioned before the lead sentence.
2962
+ * @param card - what the caller asked the card to show.
2900
2963
  * @param status - the status the card shows.
2901
2964
  * @returns card JSON.
2902
2965
  */
2903
- function buildAnalyzeRootCard(subject, status) {
2904
- const look = statusLook(status);
2966
+ function buildTopicRootCard(requesterUserId, card, status) {
2967
+ const task = plain$1(card.taskName);
2968
+ const look = statusLook(status, card.taskName);
2969
+ const elements = [markdown(`<at id=${requesterUserId}></at> ${card.lead.map((run) => run.bold === true ? `**${plain$1(run.text)}**` : plain$1(run.text)).join("")}`)];
2970
+ if (card.links !== void 0 && card.links.rows.length > 0) elements.push({
2971
+ tag: "table",
2972
+ page_size: TABLE_PAGE_SIZE,
2973
+ row_height: "low",
2974
+ header_style: {
2975
+ background_style: "grey",
2976
+ bold: true,
2977
+ lines: 1
2978
+ },
2979
+ columns: [{
2980
+ name: "name",
2981
+ display_name: card.links.nameHeader,
2982
+ data_type: "text",
2983
+ width: "auto"
2984
+ }, {
2985
+ name: "link",
2986
+ display_name: card.links.linkHeader,
2987
+ data_type: "markdown",
2988
+ width: "auto"
2989
+ }],
2990
+ rows: card.links.rows.map((row) => ({
2991
+ name: row.name,
2992
+ link: `[查看](${linkTarget(row.url)})`
2993
+ }))
2994
+ });
2995
+ elements.push(note(statusNote(status, task)));
2905
2996
  return {
2906
2997
  schema: "2.0",
2907
2998
  config: CARD_CONFIG,
2908
2999
  header: {
2909
3000
  title: {
2910
3001
  tag: "plain_text",
2911
- content: "日志分析"
3002
+ content: card.title
2912
3003
  },
2913
3004
  template: look.template,
2914
3005
  text_tag_list: [{
@@ -2920,35 +3011,7 @@ function buildAnalyzeRootCard(subject, status) {
2920
3011
  color: look.color
2921
3012
  }]
2922
3013
  },
2923
- body: { elements: [
2924
- markdown(`<at id=${subject.requesterUserId}></at> 于 ${plain$1(subject.triggeredAt)} 发起了对 **${plain$1(subject.app)}** 应用 **${plain$1(subject.errorCode)}** 错误码不良日志的分析。`),
2925
- {
2926
- tag: "table",
2927
- page_size: TABLE_PAGE_SIZE,
2928
- row_height: "low",
2929
- header_style: {
2930
- background_style: "grey",
2931
- bold: true,
2932
- lines: 1
2933
- },
2934
- columns: [{
2935
- name: "name",
2936
- display_name: "日志",
2937
- data_type: "text",
2938
- width: "auto"
2939
- }, {
2940
- name: "link",
2941
- display_name: "详情页",
2942
- data_type: "markdown",
2943
- width: "auto"
2944
- }],
2945
- rows: subject.logDetails.map((detail) => ({
2946
- name: detail.name,
2947
- link: `[查看](${linkTarget(detail.url)})`
2948
- }))
2949
- },
2950
- note(statusNote(status))
2951
- ] }
3014
+ body: { elements }
2952
3015
  };
2953
3016
  }
2954
3017
  //#endregion
@@ -6261,7 +6324,8 @@ async function storeFileResource(native, channel, msg, resource) {
6261
6324
  * - p2p: workspace `<baseDir>/p2p/<user_id>/`
6262
6325
  * - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
6263
6326
  * - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
6264
- * - log-platform analysis: workspace `<baseDir>/analyze/<app>/<errorCode>/`
6327
+ * - `feishuTopics` launch: workspace `<baseDir>/<caller segments…>/` (观象's
6328
+ * log analyses use `<baseDir>/analyze/<app>/<errorCode>/`)
6265
6329
  *
6266
6330
  * The workspace root is the Session header cwd: it keeps the Session grouped
6267
6331
  * under the user's project in the GUI and stays the sandbox write boundary.
@@ -6442,26 +6506,35 @@ async function ensureWorkspace(ctx, baseDir, chatType, chatId, senderUserId) {
6442
6506
  return ctx.workspaceRegistry.create(dir, workspaceTitleFor(chatType, chatId, senderUserId));
6443
6507
  }
6444
6508
  /**
6445
- * Workspace directory of one log-platform analysis subject:
6446
- * `<baseDir>/analyze/<app>/<errorCode>/`. Every analysis of the same
6447
- * application and error code is grouped there, each in its own topic
6448
- * directory.
6509
+ * First path segments this module lays out itself; a `feishuTopics` launch
6510
+ * workspace may not start with one of them.
6449
6511
  */
6450
- function analyzeWorkspacePathFor(baseDir, app, errorCode) {
6451
- return path.join(baseDir, "analyze", safeSegment(app, "app"), safeSegment(errorCode, "errorCode"));
6452
- }
6453
- /** Workspace title of one analysis subject in the DSH workspace list. */
6454
- function analyzeWorkspaceTitleFor(app, errorCode) {
6455
- return `日志分析 · ${app} · ${errorCode}`;
6512
+ const RESERVED_WORKSPACE_ROOTS = [
6513
+ "p2p",
6514
+ "groups",
6515
+ "topics",
6516
+ "_staging"
6517
+ ];
6518
+ /**
6519
+ * Workspace directory of one `feishuTopics` launch:
6520
+ * `<baseDir>/<segments…>/`. Launches with the same segments share it, each in
6521
+ * its own topic directory.
6522
+ * @throws when a segment is unsafe or the first one is reserved.
6523
+ */
6524
+ function launchWorkspacePathFor(baseDir, segments) {
6525
+ if (segments.length === 0) throw new Error("dsh-channels: a launch workspace needs at least one path segment");
6526
+ const safe = segments.map((segment) => safeSegment(segment, "launch workspace segment"));
6527
+ if (RESERVED_WORKSPACE_ROOTS.includes(safe[0])) throw new Error(`dsh-channels: launch workspace may not start with reserved segment ${JSON.stringify(safe[0])}`);
6528
+ return path.join(baseDir, ...safe);
6456
6529
  }
6457
6530
  /**
6458
- * Ensure one analysis subject's workspace exists and is registered (idempotent
6459
- * like {@link ensureWorkspace}).
6531
+ * Ensure one launch workspace exists and is registered (idempotent like
6532
+ * {@link ensureWorkspace}).
6460
6533
  */
6461
- async function ensureAnalyzeWorkspace(ctx, baseDir, app, errorCode) {
6462
- const dir = analyzeWorkspacePathFor(baseDir, app, errorCode);
6534
+ async function ensureLaunchWorkspace(ctx, baseDir, segments, title) {
6535
+ const dir = launchWorkspacePathFor(baseDir, segments);
6463
6536
  await mkdir(dir, { recursive: true });
6464
- return ctx.workspaceRegistry.create(dir, analyzeWorkspaceTitleFor(app, errorCode));
6537
+ return ctx.workspaceRegistry.create(dir, title);
6465
6538
  }
6466
6539
  //#endregion
6467
6540
  //#region src/session/isolation.ts
@@ -6730,7 +6803,7 @@ var InboundRouter = class {
6730
6803
  */
6731
6804
  firstDeliveries = /* @__PURE__ */ new Map();
6732
6805
  /**
6733
- * topic id → reservation by an external starter (the analyze launcher) that
6806
+ * topic id → reservation by an external starter (the `feishuTopics` launcher) that
6734
6807
  * opened the topic but has not published its route yet. A reply in the topic
6735
6808
  * waits for the release and then routes against the published record, or
6736
6809
  * falls through as an unbound topic when the start failed.
@@ -7461,116 +7534,22 @@ var InboundRouter = class {
7461
7534
  }
7462
7535
  };
7463
7536
  //#endregion
7464
- //#region src/analyze/contract.ts
7465
- /**
7466
- * Wire contract of the log-platform analyze API.
7467
- *
7468
- * `POST /api/dsh-channels.logalyze` on the DSH Web server, with
7469
- * `Authorization: Bearer <token>`, an `Idempotency-Key` header, and a JSON
7470
- * body naming the requester, the trigger time, the subject (application +
7471
- * error code), the log-platform detail pages, the target group, and the
7472
- * prompt. A valid request posts the analysis root card into the group and
7473
- * answers `202`; the analysis then streams in a topic below that card. Log
7474
- * packages are the prompt's business: the analysis skill fetches whatever the
7475
- * prompt names, and the detail pages are only listed on the card.
7476
- *
7477
- * @module dsh-channels/analyze/contract
7478
- */
7479
- /**
7480
- * Exact DSH Web route of the analyze API. It follows the host's plain-HTTP
7481
- * `/api/<name>.<method>` naming; an exact route wins over the `/api` RPC prefix
7482
- * route, so the request carries this API's bearer token, not a DSH login.
7483
- */
7484
- const ANALYZE_ROUTE = "/api/dsh-channels.logalyze";
7485
- /** Largest accepted request body (the prompt dominates it). */
7486
- const ANALYZE_MAX_BODY_BYTES = 1024 * 1024;
7487
- /** Largest accepted prompt, in characters. */
7488
- const ANALYZE_MAX_PROMPT_CHARS = 1e5;
7489
- /**
7490
- * `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
7491
- * a platform can use a UUID or its own `type:id` token.
7492
- */
7493
- const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u;
7494
- /**
7495
- * Application / error-code segment: it names a workspace directory
7496
- * (`<baseDir>/analyze/<app>/<errorCode>`), so only path-safe characters pass.
7497
- */
7498
- const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u;
7499
- /** JSON body of one analyze request. */
7500
- const analyzeRequestSchema = z.object({
7501
- /** Feishu user_id of the person who started the analysis on the platform. */
7502
- requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, "must be a Feishu user_id").max(128),
7503
- /** When the analysis was started on the platform, shown verbatim on the root card. */
7504
- triggeredAt: z.string().min(1).max(64).refine((value) => value.trim() !== "", "must not be blank"),
7505
- /** Application the logs belong to (workspace directory segment). */
7506
- app: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
7507
- /** Error code of the logs (workspace directory segment). */
7508
- errorCode: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
7509
- /** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
7510
- logDetails: z.array(z.object({
7511
- /** Row label. */
7512
- name: z.string().min(1).max(200).refine((value) => value.trim() !== "", "must not be blank"),
7513
- /** Detail page address. */
7514
- url: z.url({
7515
- protocol: /^https?$/u,
7516
- error: "must be an http(s) URL"
7517
- }).max(2048)
7518
- }).strict()).min(1).max(50),
7519
- /** Target group chat (`oc_…`); must be an authorized group. */
7520
- chatId: z.string().regex(/^oc_[A-Za-z0-9_-]{1,128}$/u, "must be a Feishu group chat_id (oc_...)"),
7521
- /** Prompt forwarded verbatim as the analysis Session's first user message. */
7522
- prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS).refine((value) => value.trim() !== "", "must not be blank")
7523
- }).strict();
7524
- /**
7525
- * Fingerprint of one validated request, used to tell a retry of the same
7526
- * request from a reused `Idempotency-Key`. Field order is fixed.
7527
- * @param request - the validated request.
7528
- * @returns SHA-256 hex digest.
7529
- */
7530
- function analyzeRequestHash(request) {
7531
- const canonical = JSON.stringify([
7532
- request.requesterUserId,
7533
- request.triggeredAt,
7534
- request.app,
7535
- request.errorCode,
7536
- request.logDetails.map((detail) => [detail.name, detail.url]),
7537
- request.chatId,
7538
- request.prompt
7539
- ]);
7540
- return createHash("sha256").update(canonical).digest("hex");
7541
- }
7542
- /**
7543
- * Validate one decoded JSON body.
7544
- * @param raw - the decoded body.
7545
- * @returns the request, or a readable list of field errors.
7546
- */
7547
- function parseAnalyzeRequest(raw) {
7548
- const parsed = analyzeRequestSchema.safeParse(raw);
7549
- if (parsed.success) return {
7550
- ok: true,
7551
- value: parsed.data
7552
- };
7553
- return {
7554
- ok: false,
7555
- error: `请求参数不合法:${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(body)"}: ${issue.message}`).join("; ")}`
7556
- };
7557
- }
7558
- //#endregion
7559
- //#region src/analyze/launcher.ts
7537
+ //#region src/topics/launcher.ts
7560
7538
  /**
7561
- * Log-platform analysis launcher: the business side of the analyze API.
7539
+ * The `feishuTopics` service: other plugins start group-shared topics (see
7540
+ * `./contract.ts`).
7562
7541
  *
7563
- * Synchronously (before the `202`): service state, target-group permission,
7564
- * `Idempotency-Key` replay, and the root card (分析中). In the background: the
7565
- * topic (opened by the first streaming card, replied in thread below the root
7566
- * card), the `<baseDir>/analyze/<app>/<errorCode>` workspace and its per-topic
7567
- * directory, the Session, the group-shared route, and the first turn: the
7568
- * platform prompt verbatim, streamed into that card like any topic turn. When
7569
- * the first turn settles, the root card's status follows it (已完成 / 已停止 /
7570
- * 失败), and the request record keeps the same status. At channel start,
7571
- * records still `running` belong to analyses a previous instance never
7572
- * finished; {@link AnalyzeLauncher.markInterrupted} marks their cards 已中断.
7573
- * Log packages are fetched by the analysis skill the prompt invokes.
7542
+ * Synchronously (before {@link TopicLauncher.launch} settles): spec
7543
+ * validation, service state, target-group permission, idempotency replay, and
7544
+ * the root card (进行中). In the background: the topic (opened by the first
7545
+ * streaming card, replied in thread below the root card), the launch workspace
7546
+ * and its per-topic directory, the Session, the group-shared route, and the
7547
+ * first turn: the prompt verbatim, streamed into that card like any topic
7548
+ * turn. When the first turn settles, the root card's status follows it
7549
+ * (已完成 / 已停止 / 失败), and the launch record keeps the same status. At
7550
+ * channel start, records still `running` belong to topics a previous instance
7551
+ * never finished; {@link TopicLauncher.markInterrupted} marks their cards
7552
+ * 已中断.
7574
7553
  *
7575
7554
  * The topic is reserved in the inbound router from its creation until the
7576
7555
  * route and the first prompt are published, so replies posted meanwhile queue
@@ -7580,152 +7559,179 @@ function parseAnalyzeRequest(raw) {
7580
7559
  * root card failed with the reason, closes the streaming card, and leaves no
7581
7560
  * route.
7582
7561
  *
7583
- * @module dsh-channels/analyze/launcher
7584
- */
7562
+ * @module dsh-channels/topics/launcher
7563
+ */
7564
+ /** Name the launcher is provided under. */
7565
+ const FEISHU_TOPICS_SERVICE = "feishuTopics";
7566
+ /** Contract version the launcher implements. */
7567
+ const FEISHU_TOPICS_VERSION = 1;
7568
+ const text = (max) => z.string().min(1).max(max).refine((value) => value.trim() !== "", "must not be blank");
7569
+ /** Launch spec validation: the rules {@link TopicLaunchSpec} documents. */
7570
+ const topicLaunchSpecSchema = z.object({
7571
+ key: z.string().regex(/^[\x21-\x7E]{1,200}$/u, "must be 1-200 visible ASCII characters"),
7572
+ fingerprint: z.string().min(1).max(512),
7573
+ chatId: z.string().regex(/^oc_[A-Za-z0-9_-]{1,128}$/u, "must be a Feishu group chat_id (oc_...)"),
7574
+ requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, "must be a Feishu user_id").max(128),
7575
+ card: z.object({
7576
+ title: text(100),
7577
+ taskName: text(20),
7578
+ lead: z.array(z.object({
7579
+ text: z.string().max(1e3),
7580
+ bold: z.boolean().optional()
7581
+ }).strict()).min(1).max(20),
7582
+ links: z.object({
7583
+ nameHeader: text(20),
7584
+ linkHeader: text(20),
7585
+ rows: z.array(z.object({
7586
+ name: text(200),
7587
+ url: z.url({
7588
+ protocol: /^https?$/u,
7589
+ error: "must be an http(s) URL"
7590
+ }).max(2048)
7591
+ }).strict()).max(50)
7592
+ }).strict().optional()
7593
+ }).strict(),
7594
+ workspace: z.object({
7595
+ segments: z.array(z.string().regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u, "must be 1-128 of A-Z a-z 0-9 . _ -")).min(1).max(8).refine((segments) => !RESERVED_WORKSPACE_ROOTS.includes(segments[0] ?? ""), `must not start with ${RESERVED_WORKSPACE_ROOTS.join(", ")}`),
7596
+ title: text(200)
7597
+ }).strict(),
7598
+ prompt: text(1e5)
7599
+ }).strict();
7585
7600
  /** Readable reason of an error value. */
7586
7601
  function messageOf$4(error) {
7587
7602
  return error instanceof Error ? error.message : String(error);
7588
7603
  }
7589
- var AnalyzeLauncher = class {
7604
+ var TopicLauncher = class {
7590
7605
  deps;
7591
- /** `Idempotency-Key` → request still being accepted (concurrent retries share it). */
7606
+ version = 1;
7607
+ /** Idempotency key → launch still being accepted (concurrent retries share it). */
7592
7608
  accepting = /* @__PURE__ */ new Map();
7593
- /** Background analyses still starting (drained on shutdown). */
7609
+ /** Background topics still starting (drained on shutdown). */
7594
7610
  running = /* @__PURE__ */ new Set();
7595
- /** Whether requests are admitted: from {@link open} after start until {@link close}. */
7611
+ /** Whether launches are admitted: from {@link open} after start until {@link close}. */
7596
7612
  admitting = false;
7597
7613
  constructor(deps) {
7598
7614
  this.deps = deps;
7599
7615
  }
7600
7616
  /**
7601
- * Start admitting requests. Called at channel start after
7602
- * {@link markInterrupted}, so a new request is never marked interrupted.
7617
+ * Start admitting launches. Called at channel start after
7618
+ * {@link markInterrupted}, so a new launch is never marked interrupted.
7603
7619
  */
7604
7620
  open() {
7605
7621
  this.admitting = true;
7606
7622
  }
7607
- /** Stop admitting requests (503) ahead of shutdown. */
7623
+ /** Refuse launches (`not-connected`) ahead of shutdown. */
7608
7624
  close() {
7609
7625
  this.admitting = false;
7610
7626
  }
7611
- enabled() {
7612
- return this.deps.config.analyzeEnabled;
7613
- }
7614
- async token() {
7615
- return await this.deps.resolveToken();
7616
- }
7617
- async analyze(request, idempotencyKey) {
7618
- const bodyHash = analyzeRequestHash(request);
7619
- const pending = this.accepting.get(idempotencyKey);
7627
+ async launch(spec) {
7628
+ const parsed = topicLaunchSpecSchema.safeParse(spec);
7629
+ if (!parsed.success) return refused("invalid", `话题参数不合法:${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(spec)"}: ${issue.message}`).join("; ")}`);
7630
+ const valid = parsed.data;
7631
+ const pending = this.accepting.get(valid.key);
7620
7632
  if (pending !== void 0) {
7621
- if (pending.bodyHash !== bodyHash) return reusedKey();
7622
- return await pending.response;
7633
+ if (pending.fingerprint !== valid.fingerprint) return reusedKey();
7634
+ return await pending.result;
7623
7635
  }
7624
- const response = this.accept(request, idempotencyKey, bodyHash);
7625
- this.accepting.set(idempotencyKey, {
7626
- bodyHash,
7627
- response
7636
+ const result = this.accept(valid);
7637
+ this.accepting.set(valid.key, {
7638
+ fingerprint: valid.fingerprint,
7639
+ result
7628
7640
  });
7629
7641
  try {
7630
- return await response;
7642
+ return await result;
7631
7643
  } finally {
7632
- this.accepting.delete(idempotencyKey);
7644
+ this.accepting.delete(valid.key);
7633
7645
  }
7634
7646
  }
7635
7647
  /**
7636
- * Wait for every acceptance in flight, then every background analysis that
7637
- * is still starting. Called after `started` turns false, so each one ends on
7648
+ * Wait for every acceptance in flight, then every background topic that is
7649
+ * still starting. Called after `started` turns false, so each one ends on
7638
7650
  * its failure card or, when its first prompt was already sent, as an
7639
7651
  * ordinary topic.
7640
7652
  */
7641
7653
  async drain() {
7642
- await Promise.allSettled([...this.accepting.values()].map((pending) => pending.response));
7654
+ await Promise.allSettled([...this.accepting.values()].map((pending) => pending.result));
7643
7655
  await Promise.allSettled([...this.running]);
7644
7656
  }
7645
7657
  /**
7646
- * Mark every analysis still `running` as interrupted, on its card and in
7647
- * its record. Called at channel start before admission opens, so each
7648
- * such record belongs to a previous instance whose first turn never
7649
- * settled. A card that cannot be updated (deleted, recalled) is logged and
7650
- * still recorded as interrupted, so it is not retried on every start.
7658
+ * Mark every launch still `running` as interrupted, on its card and in its
7659
+ * record. Called at channel start before admission opens, so each such
7660
+ * record belongs to a previous instance whose first turn never settled. A
7661
+ * card that cannot be updated (deleted, recalled) is logged and still
7662
+ * recorded as interrupted, so it is not retried on every start.
7651
7663
  */
7652
7664
  async markInterrupted() {
7653
7665
  const channel = this.deps.channel();
7654
7666
  const domain = this.deps.domain();
7655
7667
  if (channel === void 0 || domain === void 0) return;
7656
- for (const [key, record] of [...domain.analyzeRequests.entries()]) {
7668
+ for (const [key, record] of [...domain.topicLaunches.entries()]) {
7657
7669
  if (record.status !== "running") continue;
7658
- await channel.updateCard(record.messageId, buildAnalyzeRootCard(record.subject, { kind: "interrupted" })).catch((error) => {
7659
- this.deps.ctx.logger?.warn?.("dsh-channels: marking analysis card %s interrupted failed: %s", key, String(error));
7670
+ const root = rootCardOf(record);
7671
+ if (root !== void 0) await channel.updateCard(record.messageId, buildTopicRootCard(root.requesterUserId, root.card, { kind: "interrupted" })).catch((error) => {
7672
+ this.deps.ctx.logger?.warn?.("dsh-channels: marking topic card %s interrupted failed: %s", key, String(error));
7660
7673
  });
7661
7674
  await this.recordStatus(key, "interrupted");
7662
7675
  }
7663
7676
  }
7664
- async accept(request, key, bodyHash) {
7677
+ async accept(spec) {
7665
7678
  const channel = this.deps.channel();
7666
7679
  const domain = this.deps.domain();
7667
- if (!this.admitting || !this.deps.started() || channel === void 0 || domain === void 0) return refused(503, "飞书渠道尚未连接,请稍后重试");
7668
- const stored = domain.analyzeRequests.get(key);
7680
+ if (!this.admitting || !this.deps.started() || channel === void 0 || domain === void 0) return refused("not-connected", "飞书渠道尚未连接,请稍后重试");
7681
+ const stored = domain.topicLaunches.get(spec.key);
7669
7682
  if (stored !== void 0) {
7670
- if (stored.bodyHash !== bodyHash) return reusedKey();
7683
+ if (stored.bodyHash !== spec.fingerprint) return reusedKey();
7671
7684
  return {
7672
- status: 202,
7673
- body: {
7674
- ok: true,
7675
- chatId: stored.chatId,
7676
- messageId: stored.messageId
7677
- }
7685
+ ok: true,
7686
+ replayed: true,
7687
+ chatId: stored.chatId,
7688
+ messageId: stored.messageId
7678
7689
  };
7679
7690
  }
7680
- if (!this.deps.mayAnalyzeIn(request.chatId)) return refused(403, "目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加");
7681
- const subject = subjectOf(request);
7691
+ if (!this.deps.mayLaunchIn(spec.chatId)) return refused("forbidden-chat", "目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加");
7692
+ const card = copyCard(spec.card);
7682
7693
  let rootId;
7683
7694
  try {
7684
- rootId = (await channel.send(request.chatId, { card: buildAnalyzeRootCard(subject, { kind: "running" }) })).messageId;
7695
+ rootId = (await channel.send(spec.chatId, { card: buildTopicRootCard(spec.requesterUserId, card, { kind: "running" }) })).messageId;
7685
7696
  } catch (error) {
7686
- return refused(502, `分析卡片发送失败(机器人需在目标群内):${messageOf$4(error)}`);
7697
+ return refused("send-failed", `${spec.card.taskName}卡片发送失败(机器人需在目标群内):${messageOf$4(error)}`);
7687
7698
  }
7688
- const body = {
7689
- ok: true,
7690
- chatId: request.chatId,
7691
- messageId: rootId
7692
- };
7693
7699
  try {
7694
- await domain.analyzeRequests.put(key, {
7695
- key,
7696
- bodyHash,
7697
- chatId: request.chatId,
7700
+ await domain.topicLaunches.put(spec.key, {
7701
+ key: spec.key,
7702
+ bodyHash: spec.fingerprint,
7703
+ chatId: spec.chatId,
7698
7704
  messageId: rootId,
7699
7705
  acceptedAt: Date.now(),
7700
- subject: {
7701
- ...subject,
7702
- logDetails: [...subject.logDetails]
7703
- },
7706
+ requesterUserId: spec.requesterUserId,
7707
+ card,
7704
7708
  status: "running"
7705
7709
  });
7706
7710
  } catch (error) {
7707
- this.deps.ctx.logger?.warn?.("dsh-channels: recording analyze request %s failed: %s", key, String(error));
7711
+ this.deps.ctx.logger?.warn?.("dsh-channels: recording topic launch %s failed: %s", spec.key, String(error));
7708
7712
  }
7709
- const run = this.launch(channel, request, subject, rootId, key);
7713
+ const run = this.start(channel, spec, card, rootId);
7710
7714
  this.running.add(run);
7711
7715
  run.finally(() => {
7712
7716
  this.running.delete(run);
7713
7717
  });
7714
7718
  return {
7715
- status: 202,
7716
- body
7719
+ ok: true,
7720
+ replayed: false,
7721
+ chatId: spec.chatId,
7722
+ messageId: rootId
7717
7723
  };
7718
7724
  }
7719
- /** Start the analysis below an accepted root card; every failure ends on the cards. */
7720
- async launch(channel, request, subject, rootId, key) {
7725
+ /** Start the topic below an accepted root card; every failure ends on the cards. */
7726
+ async start(channel, spec, card, rootId) {
7721
7727
  const warn = (message, ...args) => {
7722
7728
  this.deps.ctx.logger?.warn?.(message, ...args);
7723
7729
  };
7724
7730
  const showStatus = async (status) => {
7725
- await channel.updateCard(rootId, buildAnalyzeRootCard(subject, status)).catch((error) => {
7726
- warn("dsh-channels: updating analysis card %s failed: %s", rootId, String(error));
7731
+ await channel.updateCard(rootId, buildTopicRootCard(spec.requesterUserId, card, status)).catch((error) => {
7732
+ warn("dsh-channels: updating topic card %s failed: %s", rootId, String(error));
7727
7733
  });
7728
- await this.recordStatus(key, status.kind);
7734
+ await this.recordStatus(spec.key, status.kind);
7729
7735
  };
7730
7736
  const sessionId = randomUUID();
7731
7737
  let handle;
@@ -7734,19 +7740,19 @@ var AnalyzeLauncher = class {
7734
7740
  let releaseTopic;
7735
7741
  let published = false;
7736
7742
  try {
7737
- const topic = await this.openTopic(channel, request.chatId, rootId, sessionId);
7743
+ const topic = await this.openTopic(channel, spec.chatId, rootId, sessionId);
7738
7744
  cardId = topic.cardId;
7739
7745
  threadId = topic.threadId;
7740
7746
  releaseTopic = this.deps.reserveTopic(threadId);
7741
7747
  const services = placementServices(this.deps.ctx);
7742
- const workspace = await ensureAnalyzeWorkspace(this.deps.ctx, this.deps.config.baseDir, request.app, request.errorCode);
7748
+ const workspace = await ensureLaunchWorkspace(this.deps.ctx, this.deps.config.baseDir, spec.workspace.segments, spec.workspace.title);
7743
7749
  const topicToken = topicTokenFor(threadId, "thread", sessionId);
7744
7750
  const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn);
7745
7751
  this.assertStarted();
7746
7752
  handle = await this.deps.sessions.createSession(sessionId, plan.cwd);
7747
7753
  const placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn);
7748
7754
  if (plan.cwd === workspace.path) Promise.resolve(workspace.attachSession(sessionId)).catch((error) => {
7749
- warn("dsh-channels: attaching analysis session %s to workspace failed: %s", sessionId, String(error));
7755
+ warn("dsh-channels: attaching topic session %s to workspace failed: %s", sessionId, String(error));
7750
7756
  });
7751
7757
  await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir);
7752
7758
  this.assertStarted();
@@ -7755,9 +7761,9 @@ var AnalyzeLauncher = class {
7755
7761
  key: threadId,
7756
7762
  kind: "thread",
7757
7763
  sessionId,
7758
- chatId: request.chatId,
7764
+ chatId: spec.chatId,
7759
7765
  chatType: "group",
7760
- senderUserId: request.requesterUserId,
7766
+ senderUserId: spec.requesterUserId,
7761
7767
  workspacePath: workspace.path,
7762
7768
  sessionDir: placement.sessionDir,
7763
7769
  ...placement.worktreeBranch === void 0 ? {} : { worktreeBranch: placement.worktreeBranch },
@@ -7773,7 +7779,7 @@ var AnalyzeLauncher = class {
7773
7779
  await this.deps.repair.write({
7774
7780
  key: cardId,
7775
7781
  sessionId,
7776
- chatId: request.chatId,
7782
+ chatId: spec.chatId,
7777
7783
  triggerMessageId: rootId,
7778
7784
  cards: [{
7779
7785
  messageId: cardId,
@@ -7792,13 +7798,13 @@ var AnalyzeLauncher = class {
7792
7798
  this.deps.streamer()?.attach(agent.session, streamCardId, rootId, void 0, { onSettled: (outcome) => {
7793
7799
  showStatus({ kind: outcome });
7794
7800
  } });
7795
- agent.followup(feishuUserMessage(request.prompt));
7801
+ agent.followup(feishuUserMessage(spec.prompt));
7796
7802
  });
7797
7803
  } catch (error) {
7798
7804
  const reason = messageOf$4(error);
7799
- warn("dsh-channels: analysis for %s/%s failed to start: %s", request.app, request.errorCode, reason);
7805
+ warn("dsh-channels: topic %s failed to start: %s", spec.key, reason);
7800
7806
  if (published && threadId !== void 0) await this.deps.routes.remove(threadId).catch((removeError) => {
7801
- warn("dsh-channels: removing analysis route %s failed: %s", threadId, String(removeError));
7807
+ warn("dsh-channels: removing topic route %s failed: %s", threadId, String(removeError));
7802
7808
  });
7803
7809
  await handle?.dispose().catch(() => {});
7804
7810
  if (cardId !== void 0) {
@@ -7806,7 +7812,7 @@ var AnalyzeLauncher = class {
7806
7812
  await channel.updateCard(cardId, buildCard({
7807
7813
  phase: "error",
7808
7814
  reasoning: "",
7809
- body: `❌ 分析未能开始:${reason}`,
7815
+ body: `❌ ${card.taskName}未能开始:${reason}`,
7810
7816
  stats: {
7811
7817
  sessionId,
7812
7818
  inputTokens: 0,
@@ -7823,8 +7829,8 @@ var AnalyzeLauncher = class {
7823
7829
  }
7824
7830
  }
7825
7831
  /**
7826
- * Open the analysis topic: the first streaming card, replied in thread below
7827
- * the root card. The topic id is read from that card (or the root card, in
7832
+ * Open the topic: the first streaming card, replied in thread below the
7833
+ * root card. The topic id is read from that card (or the root card, in
7828
7834
  * topic groups).
7829
7835
  */
7830
7836
  async openTopic(channel, chatId, rootId, sessionId) {
@@ -7851,44 +7857,52 @@ var AnalyzeLauncher = class {
7851
7857
  threadId
7852
7858
  };
7853
7859
  }
7854
- /** Store a root card status in the request record (best-effort; the card already shows it). */
7860
+ /** Store a root card status in the launch record (best-effort; the card already shows it). */
7855
7861
  async recordStatus(key, status) {
7856
7862
  const domain = this.deps.domain();
7857
- const record = domain?.analyzeRequests.get(key);
7863
+ const record = domain?.topicLaunches.get(key);
7858
7864
  if (domain === void 0 || record === void 0) return;
7859
7865
  try {
7860
- await domain.analyzeRequests.put(key, {
7866
+ await domain.topicLaunches.put(key, {
7861
7867
  ...record,
7862
7868
  status
7863
7869
  });
7864
7870
  } catch (error) {
7865
- this.deps.ctx.logger?.warn?.("dsh-channels: recording analysis %s status failed: %s", key, String(error));
7871
+ this.deps.ctx.logger?.warn?.("dsh-channels: recording topic %s status failed: %s", key, String(error));
7866
7872
  }
7867
7873
  }
7868
7874
  assertStarted() {
7869
- if (!this.deps.started()) throw new Error("飞书渠道已断开,分析已取消");
7875
+ if (!this.deps.started()) throw new Error("飞书渠道已断开,已取消");
7870
7876
  }
7871
7877
  };
7872
- function subjectOf(request) {
7878
+ /** A detached copy of the card, so the caller cannot change what is recorded. */
7879
+ function copyCard(card) {
7873
7880
  return {
7874
- requesterUserId: request.requesterUserId,
7875
- triggeredAt: request.triggeredAt,
7876
- app: request.app,
7877
- errorCode: request.errorCode,
7878
- logDetails: request.logDetails
7881
+ title: card.title,
7882
+ taskName: card.taskName,
7883
+ lead: card.lead.map((run) => run.bold === void 0 ? { text: run.text } : {
7884
+ text: run.text,
7885
+ bold: run.bold
7886
+ }),
7887
+ ...card.links === void 0 ? {} : { links: {
7888
+ nameHeader: card.links.nameHeader,
7889
+ linkHeader: card.links.linkHeader,
7890
+ rows: card.links.rows.map((row) => ({
7891
+ name: row.name,
7892
+ url: row.url
7893
+ }))
7894
+ } }
7879
7895
  };
7880
7896
  }
7881
- function refused(status, error) {
7897
+ function refused(code, message) {
7882
7898
  return {
7883
- status,
7884
- body: {
7885
- ok: false,
7886
- error
7887
- }
7899
+ ok: false,
7900
+ code,
7901
+ message
7888
7902
  };
7889
7903
  }
7890
7904
  function reusedKey() {
7891
- return refused(409, "Idempotency-Key 已被内容不同的请求使用");
7905
+ return refused("key-conflict", "幂等键已被内容不同的请求使用");
7892
7906
  }
7893
7907
  //#endregion
7894
7908
  //#region src/session/repair.ts
@@ -14981,6 +14995,8 @@ var TurnStreamer = class {
14981
14995
  toolKind: "",
14982
14996
  heartbeatTimer: void 0,
14983
14997
  attempts: /* @__PURE__ */ new Map(),
14998
+ retry: void 0,
14999
+ retries: 0,
14984
15000
  stopRequested: false,
14985
15001
  stopDenied: false,
14986
15002
  queuedDenied: false
@@ -15169,6 +15185,10 @@ var TurnStreamer = class {
15169
15185
  break;
15170
15186
  case "assistant/message":
15171
15187
  if (state.turn === void 0) state.turn = event.data.turn;
15188
+ if (state.retry !== void 0) {
15189
+ state.retry = void 0;
15190
+ this.markDirty(session.id, state);
15191
+ }
15172
15192
  if (event.data.usage !== void 0) {
15173
15193
  state.lastUsage = {
15174
15194
  usage: event.data.usage,
@@ -15250,6 +15270,24 @@ var TurnStreamer = class {
15250
15270
  }
15251
15271
  break;
15252
15272
  }
15273
+ case "llm/retry":
15274
+ if (state.turn === void 0) state.turn = event.data.turn;
15275
+ state.retry = {
15276
+ retry: event.data.retry,
15277
+ maxRetries: event.data.mode === "normal" ? event.data.maxRetries : void 0,
15278
+ code: event.data.failure.code,
15279
+ until: Date.now() + Math.max(0, event.data.delayMs),
15280
+ started: false
15281
+ };
15282
+ state.retries++;
15283
+ this.markDirty(session.id, state);
15284
+ break;
15285
+ case "llm/retry-started":
15286
+ if (state.retry !== void 0 && state.retry.retry === event.data.retry) {
15287
+ state.retry.started = true;
15288
+ this.markDirty(session.id, state);
15289
+ }
15290
+ break;
15253
15291
  case "turn/end":
15254
15292
  if (state.turn === void 0) state.turn = event.data.turn;
15255
15293
  this.finalize(session.id, state, turnFailure(event.data.reason), turnAborted(event.data.reason));
@@ -15278,6 +15316,10 @@ var TurnStreamer = class {
15278
15316
  break;
15279
15317
  case "chunk": {
15280
15318
  const chunk = frame.chunk;
15319
+ if ((chunk.type === "text-delta" || chunk.type === "reasoning-delta") && chunk.text !== "" && state.retry !== void 0) {
15320
+ state.retry = void 0;
15321
+ this.markDirty(session.id, state);
15322
+ }
15281
15323
  if (chunk.type === "text-delta") {
15282
15324
  if (chunk.text !== "") this.closeReasoning(state);
15283
15325
  const last = state.cards[state.cards.length - 1];
@@ -15297,7 +15339,7 @@ var TurnStreamer = class {
15297
15339
  this.closeReasoning(state);
15298
15340
  const mark = state.attempts.get(frame.attemptId);
15299
15341
  state.attempts.delete(frame.attemptId);
15300
- if (frame.outcome.kind === "abandoned" && mark !== void 0) {
15342
+ if ((frame.outcome.kind === "abandoned" || frame.outcome.eventType === "assistant/attempt") && mark !== void 0) {
15301
15343
  this.rollbackAttempt(state, mark);
15302
15344
  this.markDirty(session.id, state);
15303
15345
  }
@@ -15322,11 +15364,13 @@ var TurnStreamer = class {
15322
15364
  state.reasoningSince = void 0;
15323
15365
  }
15324
15366
  /**
15325
- * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
15367
+ * Drop one discarded attempt's deltas. The attempt's text is a contiguous
15326
15368
  * suffix of the logical body: segments created during the attempt are
15327
15369
  * removed, the segment that was last at `start` is truncated, and the
15328
15370
  * reasoning tail is cut. Already-delivered plain-text segments cannot be
15329
- * retracted and stop the removal.
15371
+ * retracted and stop the removal; segment cards already sent (or being
15372
+ * sent) cannot be withdrawn, so they are emptied and kept. The newest
15373
+ * remaining card is reopened so a retry streams into it again.
15330
15374
  */
15331
15375
  rollbackAttempt(state, mark) {
15332
15376
  const lastIndex = mark.cardCount - 1;
@@ -15334,6 +15378,11 @@ var TurnStreamer = class {
15334
15378
  const card = state.cards[index];
15335
15379
  if (card === void 0) continue;
15336
15380
  if (card.mode === "text" && card.messageId !== void 0) break;
15381
+ if (card.messageId !== void 0 || card.sending) {
15382
+ card.body = "";
15383
+ card.dirty = true;
15384
+ continue;
15385
+ }
15337
15386
  state.cards.splice(index, 1);
15338
15387
  }
15339
15388
  const tail = state.cards[lastIndex];
@@ -15342,6 +15391,9 @@ var TurnStreamer = class {
15342
15391
  tail.body = tail.body.slice(0, length);
15343
15392
  tail.dirty = true;
15344
15393
  }
15394
+ const last = state.cards[state.cards.length - 1];
15395
+ if (last !== void 0) if (last.mode === "text" && last.messageId !== void 0 || last.failed) state.cards.push(this.freshSegment(""));
15396
+ else last.frozen = false;
15345
15397
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength);
15346
15398
  state.reasoningMs = mark.reasoningMs;
15347
15399
  state.reasoningSince = mark.reasoningSince;
@@ -15508,6 +15560,13 @@ var TurnStreamer = class {
15508
15560
  toolLine(state, now) {
15509
15561
  if (state.finalized) return "";
15510
15562
  if (state.awaitingApproval.length > 0) return clipActivity(`⏳ 等待你审批:${[...new Set(state.awaitingApproval)].join("、")}`);
15563
+ const retry = state.retry;
15564
+ if (retry !== void 0) {
15565
+ const count = retry.maxRetries === void 0 ? `${retry.retry}` : `${retry.retry}/${retry.maxRetries}`;
15566
+ if (retry.started) return clipActivity(`🔁 模型请求重试中(第 ${count} 次)`);
15567
+ const wait = Math.max(0, Math.ceil((retry.until - now) / 1e3));
15568
+ return clipActivity(`⚠️ 模型请求失败(${retry.code}),${wait} 秒后第 ${count} 次重试`);
15569
+ }
15511
15570
  const tool = state.toolActivity;
15512
15571
  if (tool === void 0) return "";
15513
15572
  return clipActivity(`${tool.name === "subagent" ? "🤖 调用子代理:" : tool.name === "workflow" ? "🧵 调用工作流:" : "🔧 调用工具:"}${tool.label} · ${fmtElapsed(now - tool.since)}`);
@@ -15698,7 +15757,8 @@ var TurnStreamer = class {
15698
15757
  this.closeReasoning(state);
15699
15758
  if (failure !== void 0) {
15700
15759
  const code = failure.code === void 0 ? "" : `\n\n错误码:\`${failure.code}\``;
15701
- const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`;
15760
+ const retried = state.retries === 0 ? "" : `\n\n已自动重试 ${state.retries} 次`;
15761
+ const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}${retried}`;
15702
15762
  const tail = state.cards[state.cards.length - 1];
15703
15763
  if (tail !== void 0) if (tail.mode === "text" || tail.failed) state.cards.push(this.freshSegment(notice));
15704
15764
  else {
@@ -15715,6 +15775,7 @@ var TurnStreamer = class {
15715
15775
  state.heartbeatTimer = void 0;
15716
15776
  }
15717
15777
  state.tool = "";
15778
+ state.retry = void 0;
15718
15779
  this.splitOverflow(sessionId, state);
15719
15780
  for (const [index] of state.cards.entries()) this.patchCard(sessionId, state, index, true, false);
15720
15781
  for (const trigger of [state, ...state.mergedTriggers]) this.swapReaction(trigger, failure !== void 0);
@@ -15895,8 +15956,24 @@ const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 2e4;
15895
15956
  * optional context block.
15896
15957
  */
15897
15958
  const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3e3;
15898
- /** Conventional credential reference holding the log-platform analyze API bearer token. */
15899
- const DEFAULT_ANALYZE_TOKEN_REF = "DSH_CHANNELS_ANALYZE_TOKEN";
15959
+ /**
15960
+ * Keys of the log-platform analyze API, which moved to 观象
15961
+ * (`@modesto_ma/dsh-guanxiang`). A value still set in `cordis.yml` or the Web
15962
+ * overlay is ignored; the loader schema passes them through and
15963
+ * {@link movedAnalyzeKeysOf} finds them so the deployment is told.
15964
+ */
15965
+ const MOVED_ANALYZE_KEYS = ["analyzeEnabled", "analyzeToken"];
15966
+ /** Log line naming where the moved analyze API settings went. */
15967
+ const MOVED_ANALYZE_NOTICE = "dsh-channels: 日志分析接口已迁到观象(@modesto_ma/dsh-guanxiang),忽略 %s:请在观象面板「分析接口」中开启并保存令牌(GUANXIANG_ANALYZE_TOKEN)";
15968
+ /**
15969
+ * The moved analyze API keys one raw config or overlay object still sets.
15970
+ * @param raw - undecoded `config` or overlay JSON.
15971
+ * @returns the keys present, in {@link MOVED_ANALYZE_KEYS} order.
15972
+ */
15973
+ function movedAnalyzeKeysOf(raw) {
15974
+ if (typeof raw !== "object" || raw === null) return [];
15975
+ return MOVED_ANALYZE_KEYS.filter((key) => Object.hasOwn(raw, key));
15976
+ }
15900
15977
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
15901
15978
  const credentialRefSchema = z.string().regex(CREDENTIAL_REF_PATTERN, "must be a credential reference name such as FEISHU_APP_ID (resolved through the dsh credentials seam, e.g. $DSH_HOME/.credentials.yaml) — plaintext secrets are not accepted");
15902
15979
  const configSchema = z.object({
@@ -16226,28 +16303,20 @@ const configSchema = z.object({
16226
16303
  * this window fails closed with an explicit error; a host *timed* wait owns
16227
16304
  * its own deadline and is left to the host signal instead.
16228
16305
  */
16229
- questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS),
16230
- /**
16231
- * Switch for the log-platform analyze API (`POST /api/dsh-channels.logalyze`
16232
- * on the DSH Web server). Off (default) answers 404; read per request, so
16233
- * toggling it needs no reconnect. Needs a host with a Web server.
16234
- */
16235
- analyzeEnabled: z.boolean().default(false),
16236
- /**
16237
- * Credential reference holding the analyze API bearer token (defaults to
16238
- * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
16239
- * applies to the next request; an unresolvable token refuses every request.
16240
- */
16241
- analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF)
16306
+ questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS)
16242
16307
  });
16243
16308
  /**
16244
16309
  * DSH loader-facing schema. The bundle patch inserts this plugin without a
16245
16310
  * `config` object; the loader validates that row with `undefined` before the
16246
16311
  * user's patch layer supplies values. Accept that absence and fill the same
16247
16312
  * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
16248
- * cannot fail with "expected object, received undefined".
16313
+ * cannot fail with "expected object, received undefined". The moved analyze
16314
+ * keys pass through unchanged so `apply()` can warn about them.
16249
16315
  */
16250
- const pluginConfigSchema = configSchema.prefault({});
16316
+ const pluginConfigSchema = configSchema.extend({
16317
+ analyzeEnabled: z.unknown().optional(),
16318
+ analyzeToken: z.unknown().optional()
16319
+ }).prefault({});
16251
16320
  /**
16252
16321
  * Validate the raw loader config. Values stay credential *references*; the
16253
16322
  * secrets themselves resolve through `ctx.credentials` at startup.
@@ -16384,7 +16453,6 @@ const EDITABLE_KEYS = [
16384
16453
  "feishuTools.wikiRead",
16385
16454
  "feishuTools.wikiWrite",
16386
16455
  "wikiRoots",
16387
- "analyzeEnabled",
16388
16456
  "loggerLevel"
16389
16457
  ];
16390
16458
  /**
@@ -16512,11 +16580,6 @@ const PANEL_COPY = {
16512
16580
  reconnectDone: "已重启飞书连接",
16513
16581
  loading: "加载中…",
16514
16582
  loadFailed: "设置面板加载失败",
16515
- analyzeTokenLabel: "访问令牌",
16516
- analyzeTokenSummary: "请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求",
16517
- analyzeTokenPlaceholder: "填写新令牌(建议 32 位以上随机串)",
16518
- analyzeTokenSaved: "令牌已保存,下一次请求生效",
16519
- analyzeTokenRemoved: "令牌已移除,接口将拒绝所有请求",
16520
16583
  overlayTitle: "Web 覆盖",
16521
16584
  overlayPath: "覆盖文件",
16522
16585
  overlayCount: "{n} 项配置由面板覆盖",
@@ -16785,6 +16848,8 @@ var OverlayStore = class {
16785
16848
  this.logger?.warn?.("dsh-channels: Web 覆盖配置不是合法 JSON(%s),按未覆盖处理:%s", this.file, String(error));
16786
16849
  return {};
16787
16850
  }
16851
+ const moved = movedAnalyzeKeysOf(raw);
16852
+ if (moved.length > 0) this.logger?.warn?.(MOVED_ANALYZE_NOTICE, `${this.file} 中的 ${moved.join("、")}`);
16788
16853
  const parsed = parseOverlay(raw);
16789
16854
  if (!parsed.ok) {
16790
16855
  this.logger?.warn?.("dsh-channels: Web 覆盖配置含非法字段(%s),按未覆盖处理:%s", this.file, parsed.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
@@ -16863,7 +16928,7 @@ async function openFeishuDomain(ctx) {
16863
16928
  const accessUsersRaw = domain.table("access_users");
16864
16929
  const accessRequestsRaw = domain.table("access_requests");
16865
16930
  const accessWikiUsersRaw = domain.table("access_wiki_users");
16866
- const analyzeRequestsRaw = domain.table("analyze_requests");
16931
+ const topicLaunchesRaw = domain.table("analyze_requests");
16867
16932
  const sessionOriginsRaw = domain.table("session_origins");
16868
16933
  return {
16869
16934
  routes: {
@@ -16914,10 +16979,10 @@ async function openFeishuDomain(ctx) {
16914
16979
  delete: (key) => accessWikiUsersRaw.delete(key),
16915
16980
  entries: () => accessWikiUsersRaw.entries()
16916
16981
  },
16917
- analyzeRequests: {
16918
- get: (key) => analyzeRequestsRaw.get(key),
16919
- put: (key, value) => analyzeRequestsRaw.put(key, value),
16920
- entries: () => analyzeRequestsRaw.entries()
16982
+ topicLaunches: {
16983
+ get: (key) => topicLaunchesRaw.get(key),
16984
+ put: (key, value) => topicLaunchesRaw.put(key, value),
16985
+ entries: () => topicLaunchesRaw.entries()
16921
16986
  },
16922
16987
  sessionOrigins: {
16923
16988
  get: (key) => sessionOriginsRaw.get(key),
@@ -16997,8 +17062,8 @@ var FeishuService = class {
16997
17062
  messageReader = new MessageReader({ channel: () => this.channel });
16998
17063
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
16999
17064
  inbound;
17000
- /** Log-platform analyze API business side (requests → shared analysis topics). */
17001
- analyzeLauncher;
17065
+ /** The `feishuTopics` service (other plugins' launches → shared topics). */
17066
+ topicLauncher;
17002
17067
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
17003
17068
  sessions;
17004
17069
  /**
@@ -17253,7 +17318,7 @@ var FeishuService = class {
17253
17318
  handleSlash: (msg, line) => this.handleSlashMessage(msg, line)
17254
17319
  }
17255
17320
  });
17256
- this.analyzeLauncher = new AnalyzeLauncher({
17321
+ this.topicLauncher = new TopicLauncher({
17257
17322
  ctx: this.ctx,
17258
17323
  config: this.config,
17259
17324
  started: () => this.started,
@@ -17265,8 +17330,7 @@ var FeishuService = class {
17265
17330
  streamer: () => this.streamer,
17266
17331
  queue: this.routeOperations,
17267
17332
  reserveTopic: (threadId) => this.inbound.reserveTopic(threadId),
17268
- mayAnalyzeIn: (chatId) => this.accessController.mayAnalyzeIn(chatId),
17269
- resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => void 0)
17333
+ mayLaunchIn: (chatId) => this.accessController.mayLaunchIn(chatId)
17270
17334
  });
17271
17335
  }
17272
17336
  /** Effective inbound attachment backend (matches the inbound router). */
@@ -17547,9 +17611,9 @@ var FeishuService = class {
17547
17611
  this.restarting = false;
17548
17612
  }
17549
17613
  }
17550
- /** The analyze API's business side, for the Web route. */
17551
- analyzeHandler() {
17552
- return this.analyzeLauncher;
17614
+ /** The `feishuTopics` service, provided by `apply()`. */
17615
+ topics() {
17616
+ return this.topicLauncher;
17553
17617
  }
17554
17618
  isRunning() {
17555
17619
  return this.started;
@@ -18095,15 +18159,10 @@ var FeishuService = class {
18095
18159
  };
18096
18160
  }
18097
18161
  };
18098
- const [appId, appSecret, analyzeToken] = await Promise.all([
18099
- read(this.config.appId),
18100
- read(this.config.appSecret),
18101
- read(this.config.analyzeToken)
18102
- ]);
18162
+ const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)]);
18103
18163
  return {
18104
18164
  appId,
18105
- appSecret,
18106
- analyzeToken
18165
+ appSecret
18107
18166
  };
18108
18167
  }
18109
18168
  /** The host credentials seam, when mounted. */
@@ -18139,7 +18198,6 @@ var FeishuService = class {
18139
18198
  switch (field) {
18140
18199
  case "appId": return this.config.appId;
18141
18200
  case "appSecret": return this.config.appSecret;
18142
- case "analyzeToken": return this.config.analyzeToken;
18143
18201
  }
18144
18202
  }
18145
18203
  /** Wire events and open the long connection. */
@@ -18238,8 +18296,8 @@ var FeishuService = class {
18238
18296
  await channel.connect();
18239
18297
  this.runScopeCheck(channel, appId, ownerLookup);
18240
18298
  if (this.config.slashCommandPanel) this.runSlashCommandSync(channel);
18241
- await this.analyzeLauncher.markInterrupted();
18242
- this.analyzeLauncher.open();
18299
+ await this.topicLauncher.markInterrupted();
18300
+ this.topicLauncher.open();
18243
18301
  this.repair.repairAll().catch((error) => {
18244
18302
  this.ctx.logger?.warn?.("dsh-channels: inflight repair failed: %s", String(error));
18245
18303
  });
@@ -18274,8 +18332,8 @@ var FeishuService = class {
18274
18332
  /** Tear down: close the connection and dispose all live agents. */
18275
18333
  async shutdown() {
18276
18334
  this.started = false;
18277
- this.analyzeLauncher.close();
18278
- await this.analyzeLauncher.drain();
18335
+ this.topicLauncher.close();
18336
+ await this.topicLauncher.drain();
18279
18337
  for (const controller of this.commandControllers) controller.abort();
18280
18338
  await Promise.allSettled([...this.commandOperations]);
18281
18339
  this.commandControllers.clear();
@@ -19489,7 +19547,7 @@ const MAX_BODY_BYTES = 64 * 1024;
19489
19547
  function messageOf$2(error) {
19490
19548
  return error instanceof Error ? error.message : String(error);
19491
19549
  }
19492
- var HttpError$1 = class extends Error {
19550
+ var HttpError = class extends Error {
19493
19551
  status;
19494
19552
  constructor(status, message) {
19495
19553
  super(message);
@@ -19507,17 +19565,17 @@ function json(status, payload) {
19507
19565
  }
19508
19566
  /** Parse a JSON-object body; malformed bodies throw {@link HttpError} (415/413/400). */
19509
19567
  async function readJsonObject(request) {
19510
- if ((request.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") throw new HttpError$1(415, "请求体必须是 application/json");
19568
+ if ((request.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") throw new HttpError(415, "请求体必须是 application/json");
19511
19569
  const text = await request.text();
19512
- if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError$1(413, "请求体过大");
19513
- if (text === "") throw new HttpError$1(400, "请求体为空");
19570
+ if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError(413, "请求体过大");
19571
+ if (text === "") throw new HttpError(400, "请求体为空");
19514
19572
  let body;
19515
19573
  try {
19516
19574
  body = JSON.parse(text);
19517
19575
  } catch {
19518
- throw new HttpError$1(400, "请求体不是合法 JSON");
19576
+ throw new HttpError(400, "请求体不是合法 JSON");
19519
19577
  }
19520
- if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError$1(400, "请求体必须是 JSON 对象");
19578
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "请求体必须是 JSON 对象");
19521
19579
  return body;
19522
19580
  }
19523
19581
  /** Distinct non-empty entries of one comma-separated id query parameter. */
@@ -19535,7 +19593,7 @@ function answered(handler) {
19535
19593
  try {
19536
19594
  return await handler(request);
19537
19595
  } catch (error) {
19538
- if (error instanceof HttpError$1) return json(error.status, {
19596
+ if (error instanceof HttpError) return json(error.status, {
19539
19597
  ok: false,
19540
19598
  error: error.message
19541
19599
  });
@@ -19601,14 +19659,13 @@ function registerSettingsRoutes(connection, surface) {
19601
19659
  const values = {};
19602
19660
  if (payload.appId !== void 0) values.appId = payload.appId;
19603
19661
  if (payload.appSecret !== void 0) values.appSecret = payload.appSecret;
19604
- if (payload.analyzeToken !== void 0) values.analyzeToken = payload.analyzeToken;
19605
19662
  return result(await surface.saveCredentials(values));
19606
19663
  }),
19607
19664
  post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
19608
19665
  const field = (await readJsonObject(request)).field;
19609
- if (field !== "appId" && field !== "appSecret" && field !== "analyzeToken") return json(400, {
19666
+ if (field !== "appId" && field !== "appSecret") return json(400, {
19610
19667
  ok: false,
19611
- error: "field 必须是 appId、appSecret 或 analyzeToken"
19668
+ error: "field 必须是 appId 或 appSecret"
19612
19669
  });
19613
19670
  return result(await surface.clearCredential(field));
19614
19671
  }),
@@ -19672,160 +19729,6 @@ function mountSettingsRoutes(ctx, surface) {
19672
19729
  attach(ctx);
19673
19730
  }
19674
19731
  //#endregion
19675
- //#region src/analyze/server.ts
19676
- /**
19677
- * The analyze API's HTTP route on the DSH Web server.
19678
- *
19679
- * Registers the exact route `POST /api/dsh-channels.logalyze` (see
19680
- * `./contract.ts`) on the host `webServer`; hosts without one (headless) have
19681
- * no analyze API. This module owns the transport-level checks, in order:
19682
- * path, the `analyzeEnabled` switch, method, bearer token, `Idempotency-Key`,
19683
- * media type, body size, and JSON validation; everything after validation
19684
- * belongs to the {@link AnalyzeHandler}. Bodies, tokens, and prompts are never
19685
- * logged.
19686
- *
19687
- * @module dsh-channels/analyze/server
19688
- */
19689
- var HttpError = class extends Error {
19690
- status;
19691
- constructor(status, message) {
19692
- super(message);
19693
- this.status = status;
19694
- }
19695
- };
19696
- function send(res, response) {
19697
- const body = JSON.stringify(response.body);
19698
- res.writeHead(response.status, {
19699
- "content-type": "application/json; charset=utf-8",
19700
- "cache-control": "no-store",
19701
- "content-length": Buffer.byteLength(body)
19702
- });
19703
- res.end(body);
19704
- }
19705
- function refuse(res, status, error, headers = {}) {
19706
- for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
19707
- send(res, {
19708
- status,
19709
- body: {
19710
- ok: false,
19711
- error
19712
- }
19713
- });
19714
- }
19715
- /** Constant-time token comparison over fixed-length digests. */
19716
- function tokenMatches(presented, expected) {
19717
- return timingSafeEqual(createHash("sha256").update(presented).digest(), createHash("sha256").update(expected).digest());
19718
- }
19719
- /** The bearer credential of one request, or undefined. */
19720
- function bearerOf(req) {
19721
- const header = req.headers.authorization;
19722
- if (header === void 0) return void 0;
19723
- return /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)?.[1];
19724
- }
19725
- /** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
19726
- async function readBody(req) {
19727
- const declared = Number(req.headers["content-length"]);
19728
- if (Number.isFinite(declared) && declared > 1048576) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
19729
- const chunks = [];
19730
- let size = 0;
19731
- for await (const chunk of req) {
19732
- const buffer = chunk;
19733
- size += buffer.length;
19734
- if (size > 1048576) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
19735
- chunks.push(buffer);
19736
- }
19737
- return Buffer.concat(chunks).toString("utf8");
19738
- }
19739
- /**
19740
- * Handle one HTTP request against the analyze contract.
19741
- * @param req - the incoming request.
19742
- * @param res - its response.
19743
- * @param handler - the business side.
19744
- */
19745
- async function handleAnalyzeHttp(req, res, handler) {
19746
- if (new URL(req.url ?? "/", "http://analyze.invalid").pathname !== "/api/dsh-channels.logalyze") {
19747
- refuse(res, 404, "未知路径");
19748
- return;
19749
- }
19750
- if (!handler.enabled()) {
19751
- refuse(res, 404, "日志分析接口未开启,请在设置面板「高级 → 日志分析接口」打开开关");
19752
- return;
19753
- }
19754
- if ((req.method ?? "GET").toUpperCase() !== "POST") {
19755
- refuse(res, 405, "只接受 POST", { allow: "POST" });
19756
- return;
19757
- }
19758
- const expected = await handler.token();
19759
- if (expected === void 0 || expected === "") {
19760
- refuse(res, 503, "分析接口未配置访问令牌,请联系部署者配置 analyzeToken 引用的凭据");
19761
- return;
19762
- }
19763
- const presented = bearerOf(req);
19764
- if (presented === void 0 || !tokenMatches(presented, expected)) {
19765
- refuse(res, 401, "访问令牌无效", { "www-authenticate": "Bearer" });
19766
- return;
19767
- }
19768
- const idempotencyKey = req.headers["idempotency-key"];
19769
- if (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
19770
- refuse(res, 400, "缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)");
19771
- return;
19772
- }
19773
- if ((req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") {
19774
- refuse(res, 415, "Content-Type 必须是 application/json");
19775
- return;
19776
- }
19777
- let raw;
19778
- try {
19779
- raw = JSON.parse(await readBody(req));
19780
- } catch (error) {
19781
- if (error instanceof HttpError) {
19782
- refuse(res, error.status, error.message);
19783
- return;
19784
- }
19785
- refuse(res, 400, "请求体不是合法的 JSON");
19786
- return;
19787
- }
19788
- const parsed = parseAnalyzeRequest(raw);
19789
- if (!parsed.ok) {
19790
- refuse(res, 400, parsed.error);
19791
- return;
19792
- }
19793
- send(res, await handler.analyze(parsed.value, idempotencyKey));
19794
- }
19795
- /**
19796
- * Register the analyze route on the host Web server once it is available.
19797
- * Requests skip the DSH login gate on purpose: the bearer token is this API's
19798
- * only credential.
19799
- * @param ctx - the plugin context.
19800
- * @param handler - the business side.
19801
- * @param warn - diagnostics sink for unexpected handler failures.
19802
- */
19803
- function mountAnalyzeRoute(ctx, handler, warn) {
19804
- const attach = (webCtx) => {
19805
- const server = webCtx.get?.("webServer");
19806
- if (server === void 0) return;
19807
- webCtx.effect(() => server.register({
19808
- kind: "exact",
19809
- path: ANALYZE_ROUTE,
19810
- handler: async (req, res) => {
19811
- try {
19812
- await handleAnalyzeHttp(req, res, handler);
19813
- } catch (error) {
19814
- warn("dsh-channels: analyze request failed: %s", String(error));
19815
- if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`);
19816
- else res.destroy();
19817
- }
19818
- }
19819
- }), "dsh-channels: analyze route");
19820
- };
19821
- const inject = ctx.inject;
19822
- if (typeof inject === "function") {
19823
- inject.call(ctx, ["webServer"], attach);
19824
- return;
19825
- }
19826
- attach(ctx);
19827
- }
19828
- //#endregion
19829
19732
  //#region src/settings/surface.ts
19830
19733
  /**
19831
19734
  * Web 设置面板的 Host 视图模型:把 `FeishuService` 的运行时事实、覆盖层与
@@ -19840,11 +19743,7 @@ function mountAnalyzeRoute(ctx, handler, warn) {
19840
19743
  */
19841
19744
  const RESTART_SET = new Set(RESTART_KEYS);
19842
19745
  /** Every credential slot the panel writes, in form order. */
19843
- const CREDENTIAL_FIELDS = [
19844
- "appId",
19845
- "appSecret",
19846
- "analyzeToken"
19847
- ];
19746
+ const CREDENTIAL_FIELDS = ["appId", "appSecret"];
19848
19747
  /** Optional fields: an empty string in the panel clears the override. */
19849
19748
  const OPTIONAL_TEXT_KEYS = /* @__PURE__ */ new Set([
19850
19749
  "provider",
@@ -19866,8 +19765,7 @@ const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
19866
19765
  "feishuTools.topicMessages",
19867
19766
  "feishuTools.sendFile",
19868
19767
  "feishuTools.wikiRead",
19869
- "feishuTools.wikiWrite",
19870
- "analyzeEnabled"
19768
+ "feishuTools.wikiWrite"
19871
19769
  ]);
19872
19770
  /** Editable keys rendered as `string-list` (one input per entry). */
19873
19771
  const STRING_LIST_FIELDS = { wikiRoots: {
@@ -20308,12 +20206,6 @@ const FIELD_META = {
20308
20206
  placeholder: "https://<租户>.feishu.cn/wiki/space/<space_id>",
20309
20207
  wide: true
20310
20208
  },
20311
- analyzeEnabled: {
20312
- label: "日志分析接口",
20313
- group: "analyze",
20314
- summary: "关闭时接口返回 404",
20315
- help: "接口挂在 DSH Web 端口上:POST /api/dsh-channels.logalyze,请求需带 Authorization: Bearer <访问令牌>。只有带 Web 的 profile 才有这个接口。改后立即生效,不需要重连。"
20316
- },
20317
20209
  loggerLevel: {
20318
20210
  label: "SDK 日志级别",
20319
20211
  group: "logging"
@@ -20664,10 +20556,6 @@ var SettingsSurface = class {
20664
20556
  appSecret: {
20665
20557
  configured: false,
20666
20558
  writable: false
20667
- },
20668
- analyzeToken: {
20669
- configured: false,
20670
- writable: false
20671
20559
  }
20672
20560
  };
20673
20561
  try {
@@ -20683,8 +20571,7 @@ var SettingsSurface = class {
20683
20571
  });
20684
20572
  const credentials = {
20685
20573
  appId: toWire(effective.appId, credentialStates.appId),
20686
- appSecret: toWire(effective.appSecret, credentialStates.appSecret),
20687
- analyzeToken: toWire(effective.analyzeToken, credentialStates.analyzeToken)
20574
+ appSecret: toWire(effective.appSecret, credentialStates.appSecret)
20688
20575
  };
20689
20576
  let access;
20690
20577
  try {
@@ -20860,11 +20747,6 @@ var SettingsSurface = class {
20860
20747
  error: messageOf$1(error)
20861
20748
  };
20862
20749
  }
20863
- if (writes.every((write) => write.field === "analyzeToken")) return {
20864
- ok: true,
20865
- panel: await this.panel(),
20866
- reconnect: "not-needed"
20867
- };
20868
20750
  return this.finishCredentialChange("save");
20869
20751
  }
20870
20752
  /** Remove the stored value behind one credential reference. */
@@ -20883,11 +20765,6 @@ var SettingsSurface = class {
20883
20765
  error: messageOf$1(error)
20884
20766
  };
20885
20767
  }
20886
- if (field === "analyzeToken") return {
20887
- ok: true,
20888
- panel: await this.panel(),
20889
- reconnect: "not-needed"
20890
- };
20891
20768
  return this.finishCredentialChange("clear");
20892
20769
  }
20893
20770
  /**
@@ -21414,7 +21291,9 @@ function apply(ctx, config) {
21414
21291
  ctx.logger?.warn?.(message, ...args);
21415
21292
  };
21416
21293
  mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn), createSettingsDirectoryPicker(ctx)));
21417
- mountAnalyzeRoute(ctx, service.analyzeHandler(), warn);
21294
+ ctx.provide?.(FEISHU_TOPICS_SERVICE, service.topics());
21295
+ const moved = movedAnalyzeKeysOf(config);
21296
+ if (moved.length > 0) warn(MOVED_ANALYZE_NOTICE, `cordis.yml 中的 ${moved.join("、")}`);
21418
21297
  ctx.effect(() => {
21419
21298
  service.boot().catch((error) => {
21420
21299
  ctx.logger?.error?.("dsh-channels failed to boot: %s", String(error));
@@ -21424,6 +21303,6 @@ function apply(ctx, config) {
21424
21303
  }, "dsh-channels");
21425
21304
  }
21426
21305
  //#endregion
21427
- export { AUTH_USER_TARGET_ERRORS, AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEAR_CONFIG_ROUTE, CLEAR_CREDENTIAL_ROUTE, Config, DM_DENY_TEXT, DM_DENY_WHITELIST_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SAVE_CONFIG_ROUTE, SAVE_CREDENTIALS_ROUTE, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminUserId, isSupportedCommand, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveAuthUserTarget, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionDirOf, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stagingRoot, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicDirFor, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor, worktreeBranchFor };
21306
+ export { AUTH_USER_TARGET_ERRORS, AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEAR_CONFIG_ROUTE, CLEAR_CREDENTIAL_ROUTE, Config, DM_DENY_TEXT, DM_DENY_WHITELIST_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FEISHU_TOPICS_SERVICE, FEISHU_TOPICS_VERSION, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SAVE_CONFIG_ROUTE, SAVE_CREDENTIALS_ROUTE, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TopicLauncher, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildTopicRootCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminUserId, isSupportedCommand, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveAuthUserTarget, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionDirOf, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stagingRoot, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicDirFor, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor, worktreeBranchFor };
21428
21307
 
21429
21308
  //# sourceMappingURL=index.js.map