@modesto_ma/dsh-channels 0.7.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,117 +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 /open-api/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 sits outside DSH's `/api`
7481
- * namespace so a gateway in front of DSH can expose it separately from the
7482
- * login-protected DSH API; the request carries this API's bearer token, not a
7483
- * DSH login.
7484
- */
7485
- const ANALYZE_ROUTE = "/open-api/logalyze";
7486
- /** Largest accepted request body (the prompt dominates it). */
7487
- const ANALYZE_MAX_BODY_BYTES = 1024 * 1024;
7488
- /** Largest accepted prompt, in characters. */
7489
- const ANALYZE_MAX_PROMPT_CHARS = 1e5;
7490
- /**
7491
- * `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
7492
- * a platform can use a UUID or its own `type:id` token.
7493
- */
7494
- const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u;
7495
- /**
7496
- * Application / error-code segment: it names a workspace directory
7497
- * (`<baseDir>/analyze/<app>/<errorCode>`), so only path-safe characters pass.
7498
- */
7499
- const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u;
7500
- /** JSON body of one analyze request. */
7501
- const analyzeRequestSchema = z.object({
7502
- /** Feishu user_id of the person who started the analysis on the platform. */
7503
- requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, "must be a Feishu user_id").max(128),
7504
- /** When the analysis was started on the platform, shown verbatim on the root card. */
7505
- triggeredAt: z.string().min(1).max(64).refine((value) => value.trim() !== "", "must not be blank"),
7506
- /** Application the logs belong to (workspace directory segment). */
7507
- app: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
7508
- /** Error code of the logs (workspace directory segment). */
7509
- errorCode: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
7510
- /** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
7511
- logDetails: z.array(z.object({
7512
- /** Row label. */
7513
- name: z.string().min(1).max(200).refine((value) => value.trim() !== "", "must not be blank"),
7514
- /** Detail page address. */
7515
- url: z.url({
7516
- protocol: /^https?$/u,
7517
- error: "must be an http(s) URL"
7518
- }).max(2048)
7519
- }).strict()).min(1).max(50),
7520
- /** Target group chat (`oc_…`); must be an authorized group. */
7521
- chatId: z.string().regex(/^oc_[A-Za-z0-9_-]{1,128}$/u, "must be a Feishu group chat_id (oc_...)"),
7522
- /** Prompt forwarded verbatim as the analysis Session's first user message. */
7523
- prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS).refine((value) => value.trim() !== "", "must not be blank")
7524
- }).strict();
7525
- /**
7526
- * Fingerprint of one validated request, used to tell a retry of the same
7527
- * request from a reused `Idempotency-Key`. Field order is fixed.
7528
- * @param request - the validated request.
7529
- * @returns SHA-256 hex digest.
7530
- */
7531
- function analyzeRequestHash(request) {
7532
- const canonical = JSON.stringify([
7533
- request.requesterUserId,
7534
- request.triggeredAt,
7535
- request.app,
7536
- request.errorCode,
7537
- request.logDetails.map((detail) => [detail.name, detail.url]),
7538
- request.chatId,
7539
- request.prompt
7540
- ]);
7541
- return createHash("sha256").update(canonical).digest("hex");
7542
- }
7543
- /**
7544
- * Validate one decoded JSON body.
7545
- * @param raw - the decoded body.
7546
- * @returns the request, or a readable list of field errors.
7547
- */
7548
- function parseAnalyzeRequest(raw) {
7549
- const parsed = analyzeRequestSchema.safeParse(raw);
7550
- if (parsed.success) return {
7551
- ok: true,
7552
- value: parsed.data
7553
- };
7554
- return {
7555
- ok: false,
7556
- error: `请求参数不合法:${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(body)"}: ${issue.message}`).join("; ")}`
7557
- };
7558
- }
7559
- //#endregion
7560
- //#region src/analyze/launcher.ts
7537
+ //#region src/topics/launcher.ts
7561
7538
  /**
7562
- * 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`).
7563
7541
  *
7564
- * Synchronously (before the `202`): service state, target-group permission,
7565
- * `Idempotency-Key` replay, and the root card (分析中). In the background: the
7566
- * topic (opened by the first streaming card, replied in thread below the root
7567
- * card), the `<baseDir>/analyze/<app>/<errorCode>` workspace and its per-topic
7568
- * directory, the Session, the group-shared route, and the first turn: the
7569
- * platform prompt verbatim, streamed into that card like any topic turn. When
7570
- * the first turn settles, the root card's status follows it (已完成 / 已停止 /
7571
- * 失败), and the request record keeps the same status. At channel start,
7572
- * records still `running` belong to analyses a previous instance never
7573
- * finished; {@link AnalyzeLauncher.markInterrupted} marks their cards 已中断.
7574
- * 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
+ * 已中断.
7575
7553
  *
7576
7554
  * The topic is reserved in the inbound router from its creation until the
7577
7555
  * route and the first prompt are published, so replies posted meanwhile queue
@@ -7581,152 +7559,179 @@ function parseAnalyzeRequest(raw) {
7581
7559
  * root card failed with the reason, closes the streaming card, and leaves no
7582
7560
  * route.
7583
7561
  *
7584
- * @module dsh-channels/analyze/launcher
7585
- */
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();
7586
7600
  /** Readable reason of an error value. */
7587
7601
  function messageOf$4(error) {
7588
7602
  return error instanceof Error ? error.message : String(error);
7589
7603
  }
7590
- var AnalyzeLauncher = class {
7604
+ var TopicLauncher = class {
7591
7605
  deps;
7592
- /** `Idempotency-Key` → request still being accepted (concurrent retries share it). */
7606
+ version = 1;
7607
+ /** Idempotency key → launch still being accepted (concurrent retries share it). */
7593
7608
  accepting = /* @__PURE__ */ new Map();
7594
- /** Background analyses still starting (drained on shutdown). */
7609
+ /** Background topics still starting (drained on shutdown). */
7595
7610
  running = /* @__PURE__ */ new Set();
7596
- /** 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}. */
7597
7612
  admitting = false;
7598
7613
  constructor(deps) {
7599
7614
  this.deps = deps;
7600
7615
  }
7601
7616
  /**
7602
- * Start admitting requests. Called at channel start after
7603
- * {@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.
7604
7619
  */
7605
7620
  open() {
7606
7621
  this.admitting = true;
7607
7622
  }
7608
- /** Stop admitting requests (503) ahead of shutdown. */
7623
+ /** Refuse launches (`not-connected`) ahead of shutdown. */
7609
7624
  close() {
7610
7625
  this.admitting = false;
7611
7626
  }
7612
- enabled() {
7613
- return this.deps.config.analyzeEnabled;
7614
- }
7615
- async token() {
7616
- return await this.deps.resolveToken();
7617
- }
7618
- async analyze(request, idempotencyKey) {
7619
- const bodyHash = analyzeRequestHash(request);
7620
- 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);
7621
7632
  if (pending !== void 0) {
7622
- if (pending.bodyHash !== bodyHash) return reusedKey();
7623
- return await pending.response;
7633
+ if (pending.fingerprint !== valid.fingerprint) return reusedKey();
7634
+ return await pending.result;
7624
7635
  }
7625
- const response = this.accept(request, idempotencyKey, bodyHash);
7626
- this.accepting.set(idempotencyKey, {
7627
- bodyHash,
7628
- response
7636
+ const result = this.accept(valid);
7637
+ this.accepting.set(valid.key, {
7638
+ fingerprint: valid.fingerprint,
7639
+ result
7629
7640
  });
7630
7641
  try {
7631
- return await response;
7642
+ return await result;
7632
7643
  } finally {
7633
- this.accepting.delete(idempotencyKey);
7644
+ this.accepting.delete(valid.key);
7634
7645
  }
7635
7646
  }
7636
7647
  /**
7637
- * Wait for every acceptance in flight, then every background analysis that
7638
- * 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
7639
7650
  * its failure card or, when its first prompt was already sent, as an
7640
7651
  * ordinary topic.
7641
7652
  */
7642
7653
  async drain() {
7643
- await Promise.allSettled([...this.accepting.values()].map((pending) => pending.response));
7654
+ await Promise.allSettled([...this.accepting.values()].map((pending) => pending.result));
7644
7655
  await Promise.allSettled([...this.running]);
7645
7656
  }
7646
7657
  /**
7647
- * Mark every analysis still `running` as interrupted, on its card and in
7648
- * its record. Called at channel start before admission opens, so each
7649
- * such record belongs to a previous instance whose first turn never
7650
- * settled. A card that cannot be updated (deleted, recalled) is logged and
7651
- * 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.
7652
7663
  */
7653
7664
  async markInterrupted() {
7654
7665
  const channel = this.deps.channel();
7655
7666
  const domain = this.deps.domain();
7656
7667
  if (channel === void 0 || domain === void 0) return;
7657
- for (const [key, record] of [...domain.analyzeRequests.entries()]) {
7668
+ for (const [key, record] of [...domain.topicLaunches.entries()]) {
7658
7669
  if (record.status !== "running") continue;
7659
- await channel.updateCard(record.messageId, buildAnalyzeRootCard(record.subject, { kind: "interrupted" })).catch((error) => {
7660
- 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));
7661
7673
  });
7662
7674
  await this.recordStatus(key, "interrupted");
7663
7675
  }
7664
7676
  }
7665
- async accept(request, key, bodyHash) {
7677
+ async accept(spec) {
7666
7678
  const channel = this.deps.channel();
7667
7679
  const domain = this.deps.domain();
7668
- if (!this.admitting || !this.deps.started() || channel === void 0 || domain === void 0) return refused(503, "飞书渠道尚未连接,请稍后重试");
7669
- 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);
7670
7682
  if (stored !== void 0) {
7671
- if (stored.bodyHash !== bodyHash) return reusedKey();
7683
+ if (stored.bodyHash !== spec.fingerprint) return reusedKey();
7672
7684
  return {
7673
- status: 202,
7674
- body: {
7675
- ok: true,
7676
- chatId: stored.chatId,
7677
- messageId: stored.messageId
7678
- }
7685
+ ok: true,
7686
+ replayed: true,
7687
+ chatId: stored.chatId,
7688
+ messageId: stored.messageId
7679
7689
  };
7680
7690
  }
7681
- if (!this.deps.mayAnalyzeIn(request.chatId)) return refused(403, "目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加");
7682
- const subject = subjectOf(request);
7691
+ if (!this.deps.mayLaunchIn(spec.chatId)) return refused("forbidden-chat", "目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加");
7692
+ const card = copyCard(spec.card);
7683
7693
  let rootId;
7684
7694
  try {
7685
- 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;
7686
7696
  } catch (error) {
7687
- return refused(502, `分析卡片发送失败(机器人需在目标群内):${messageOf$4(error)}`);
7697
+ return refused("send-failed", `${spec.card.taskName}卡片发送失败(机器人需在目标群内):${messageOf$4(error)}`);
7688
7698
  }
7689
- const body = {
7690
- ok: true,
7691
- chatId: request.chatId,
7692
- messageId: rootId
7693
- };
7694
7699
  try {
7695
- await domain.analyzeRequests.put(key, {
7696
- key,
7697
- bodyHash,
7698
- chatId: request.chatId,
7700
+ await domain.topicLaunches.put(spec.key, {
7701
+ key: spec.key,
7702
+ bodyHash: spec.fingerprint,
7703
+ chatId: spec.chatId,
7699
7704
  messageId: rootId,
7700
7705
  acceptedAt: Date.now(),
7701
- subject: {
7702
- ...subject,
7703
- logDetails: [...subject.logDetails]
7704
- },
7706
+ requesterUserId: spec.requesterUserId,
7707
+ card,
7705
7708
  status: "running"
7706
7709
  });
7707
7710
  } catch (error) {
7708
- 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));
7709
7712
  }
7710
- const run = this.launch(channel, request, subject, rootId, key);
7713
+ const run = this.start(channel, spec, card, rootId);
7711
7714
  this.running.add(run);
7712
7715
  run.finally(() => {
7713
7716
  this.running.delete(run);
7714
7717
  });
7715
7718
  return {
7716
- status: 202,
7717
- body
7719
+ ok: true,
7720
+ replayed: false,
7721
+ chatId: spec.chatId,
7722
+ messageId: rootId
7718
7723
  };
7719
7724
  }
7720
- /** Start the analysis below an accepted root card; every failure ends on the cards. */
7721
- 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) {
7722
7727
  const warn = (message, ...args) => {
7723
7728
  this.deps.ctx.logger?.warn?.(message, ...args);
7724
7729
  };
7725
7730
  const showStatus = async (status) => {
7726
- await channel.updateCard(rootId, buildAnalyzeRootCard(subject, status)).catch((error) => {
7727
- 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));
7728
7733
  });
7729
- await this.recordStatus(key, status.kind);
7734
+ await this.recordStatus(spec.key, status.kind);
7730
7735
  };
7731
7736
  const sessionId = randomUUID();
7732
7737
  let handle;
@@ -7735,19 +7740,19 @@ var AnalyzeLauncher = class {
7735
7740
  let releaseTopic;
7736
7741
  let published = false;
7737
7742
  try {
7738
- const topic = await this.openTopic(channel, request.chatId, rootId, sessionId);
7743
+ const topic = await this.openTopic(channel, spec.chatId, rootId, sessionId);
7739
7744
  cardId = topic.cardId;
7740
7745
  threadId = topic.threadId;
7741
7746
  releaseTopic = this.deps.reserveTopic(threadId);
7742
7747
  const services = placementServices(this.deps.ctx);
7743
- 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);
7744
7749
  const topicToken = topicTokenFor(threadId, "thread", sessionId);
7745
7750
  const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn);
7746
7751
  this.assertStarted();
7747
7752
  handle = await this.deps.sessions.createSession(sessionId, plan.cwd);
7748
7753
  const placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn);
7749
7754
  if (plan.cwd === workspace.path) Promise.resolve(workspace.attachSession(sessionId)).catch((error) => {
7750
- 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));
7751
7756
  });
7752
7757
  await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir);
7753
7758
  this.assertStarted();
@@ -7756,9 +7761,9 @@ var AnalyzeLauncher = class {
7756
7761
  key: threadId,
7757
7762
  kind: "thread",
7758
7763
  sessionId,
7759
- chatId: request.chatId,
7764
+ chatId: spec.chatId,
7760
7765
  chatType: "group",
7761
- senderUserId: request.requesterUserId,
7766
+ senderUserId: spec.requesterUserId,
7762
7767
  workspacePath: workspace.path,
7763
7768
  sessionDir: placement.sessionDir,
7764
7769
  ...placement.worktreeBranch === void 0 ? {} : { worktreeBranch: placement.worktreeBranch },
@@ -7774,7 +7779,7 @@ var AnalyzeLauncher = class {
7774
7779
  await this.deps.repair.write({
7775
7780
  key: cardId,
7776
7781
  sessionId,
7777
- chatId: request.chatId,
7782
+ chatId: spec.chatId,
7778
7783
  triggerMessageId: rootId,
7779
7784
  cards: [{
7780
7785
  messageId: cardId,
@@ -7793,13 +7798,13 @@ var AnalyzeLauncher = class {
7793
7798
  this.deps.streamer()?.attach(agent.session, streamCardId, rootId, void 0, { onSettled: (outcome) => {
7794
7799
  showStatus({ kind: outcome });
7795
7800
  } });
7796
- agent.followup(feishuUserMessage(request.prompt));
7801
+ agent.followup(feishuUserMessage(spec.prompt));
7797
7802
  });
7798
7803
  } catch (error) {
7799
7804
  const reason = messageOf$4(error);
7800
- 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);
7801
7806
  if (published && threadId !== void 0) await this.deps.routes.remove(threadId).catch((removeError) => {
7802
- 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));
7803
7808
  });
7804
7809
  await handle?.dispose().catch(() => {});
7805
7810
  if (cardId !== void 0) {
@@ -7807,7 +7812,7 @@ var AnalyzeLauncher = class {
7807
7812
  await channel.updateCard(cardId, buildCard({
7808
7813
  phase: "error",
7809
7814
  reasoning: "",
7810
- body: `❌ 分析未能开始:${reason}`,
7815
+ body: `❌ ${card.taskName}未能开始:${reason}`,
7811
7816
  stats: {
7812
7817
  sessionId,
7813
7818
  inputTokens: 0,
@@ -7824,8 +7829,8 @@ var AnalyzeLauncher = class {
7824
7829
  }
7825
7830
  }
7826
7831
  /**
7827
- * Open the analysis topic: the first streaming card, replied in thread below
7828
- * 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
7829
7834
  * topic groups).
7830
7835
  */
7831
7836
  async openTopic(channel, chatId, rootId, sessionId) {
@@ -7852,44 +7857,52 @@ var AnalyzeLauncher = class {
7852
7857
  threadId
7853
7858
  };
7854
7859
  }
7855
- /** 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). */
7856
7861
  async recordStatus(key, status) {
7857
7862
  const domain = this.deps.domain();
7858
- const record = domain?.analyzeRequests.get(key);
7863
+ const record = domain?.topicLaunches.get(key);
7859
7864
  if (domain === void 0 || record === void 0) return;
7860
7865
  try {
7861
- await domain.analyzeRequests.put(key, {
7866
+ await domain.topicLaunches.put(key, {
7862
7867
  ...record,
7863
7868
  status
7864
7869
  });
7865
7870
  } catch (error) {
7866
- 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));
7867
7872
  }
7868
7873
  }
7869
7874
  assertStarted() {
7870
- if (!this.deps.started()) throw new Error("飞书渠道已断开,分析已取消");
7875
+ if (!this.deps.started()) throw new Error("飞书渠道已断开,已取消");
7871
7876
  }
7872
7877
  };
7873
- function subjectOf(request) {
7878
+ /** A detached copy of the card, so the caller cannot change what is recorded. */
7879
+ function copyCard(card) {
7874
7880
  return {
7875
- requesterUserId: request.requesterUserId,
7876
- triggeredAt: request.triggeredAt,
7877
- app: request.app,
7878
- errorCode: request.errorCode,
7879
- 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
+ } }
7880
7895
  };
7881
7896
  }
7882
- function refused(status, error) {
7897
+ function refused(code, message) {
7883
7898
  return {
7884
- status,
7885
- body: {
7886
- ok: false,
7887
- error
7888
- }
7899
+ ok: false,
7900
+ code,
7901
+ message
7889
7902
  };
7890
7903
  }
7891
7904
  function reusedKey() {
7892
- return refused(409, "Idempotency-Key 已被内容不同的请求使用");
7905
+ return refused("key-conflict", "幂等键已被内容不同的请求使用");
7893
7906
  }
7894
7907
  //#endregion
7895
7908
  //#region src/session/repair.ts
@@ -14982,6 +14995,8 @@ var TurnStreamer = class {
14982
14995
  toolKind: "",
14983
14996
  heartbeatTimer: void 0,
14984
14997
  attempts: /* @__PURE__ */ new Map(),
14998
+ retry: void 0,
14999
+ retries: 0,
14985
15000
  stopRequested: false,
14986
15001
  stopDenied: false,
14987
15002
  queuedDenied: false
@@ -15170,6 +15185,10 @@ var TurnStreamer = class {
15170
15185
  break;
15171
15186
  case "assistant/message":
15172
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
+ }
15173
15192
  if (event.data.usage !== void 0) {
15174
15193
  state.lastUsage = {
15175
15194
  usage: event.data.usage,
@@ -15251,6 +15270,24 @@ var TurnStreamer = class {
15251
15270
  }
15252
15271
  break;
15253
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;
15254
15291
  case "turn/end":
15255
15292
  if (state.turn === void 0) state.turn = event.data.turn;
15256
15293
  this.finalize(session.id, state, turnFailure(event.data.reason), turnAborted(event.data.reason));
@@ -15279,6 +15316,10 @@ var TurnStreamer = class {
15279
15316
  break;
15280
15317
  case "chunk": {
15281
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
+ }
15282
15323
  if (chunk.type === "text-delta") {
15283
15324
  if (chunk.text !== "") this.closeReasoning(state);
15284
15325
  const last = state.cards[state.cards.length - 1];
@@ -15298,7 +15339,7 @@ var TurnStreamer = class {
15298
15339
  this.closeReasoning(state);
15299
15340
  const mark = state.attempts.get(frame.attemptId);
15300
15341
  state.attempts.delete(frame.attemptId);
15301
- if (frame.outcome.kind === "abandoned" && mark !== void 0) {
15342
+ if ((frame.outcome.kind === "abandoned" || frame.outcome.eventType === "assistant/attempt") && mark !== void 0) {
15302
15343
  this.rollbackAttempt(state, mark);
15303
15344
  this.markDirty(session.id, state);
15304
15345
  }
@@ -15323,11 +15364,13 @@ var TurnStreamer = class {
15323
15364
  state.reasoningSince = void 0;
15324
15365
  }
15325
15366
  /**
15326
- * 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
15327
15368
  * suffix of the logical body: segments created during the attempt are
15328
15369
  * removed, the segment that was last at `start` is truncated, and the
15329
15370
  * reasoning tail is cut. Already-delivered plain-text segments cannot be
15330
- * 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.
15331
15374
  */
15332
15375
  rollbackAttempt(state, mark) {
15333
15376
  const lastIndex = mark.cardCount - 1;
@@ -15335,6 +15378,11 @@ var TurnStreamer = class {
15335
15378
  const card = state.cards[index];
15336
15379
  if (card === void 0) continue;
15337
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
+ }
15338
15386
  state.cards.splice(index, 1);
15339
15387
  }
15340
15388
  const tail = state.cards[lastIndex];
@@ -15343,6 +15391,9 @@ var TurnStreamer = class {
15343
15391
  tail.body = tail.body.slice(0, length);
15344
15392
  tail.dirty = true;
15345
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;
15346
15397
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength);
15347
15398
  state.reasoningMs = mark.reasoningMs;
15348
15399
  state.reasoningSince = mark.reasoningSince;
@@ -15509,6 +15560,13 @@ var TurnStreamer = class {
15509
15560
  toolLine(state, now) {
15510
15561
  if (state.finalized) return "";
15511
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
+ }
15512
15570
  const tool = state.toolActivity;
15513
15571
  if (tool === void 0) return "";
15514
15572
  return clipActivity(`${tool.name === "subagent" ? "🤖 调用子代理:" : tool.name === "workflow" ? "🧵 调用工作流:" : "🔧 调用工具:"}${tool.label} · ${fmtElapsed(now - tool.since)}`);
@@ -15699,7 +15757,8 @@ var TurnStreamer = class {
15699
15757
  this.closeReasoning(state);
15700
15758
  if (failure !== void 0) {
15701
15759
  const code = failure.code === void 0 ? "" : `\n\n错误码:\`${failure.code}\``;
15702
- 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}`;
15703
15762
  const tail = state.cards[state.cards.length - 1];
15704
15763
  if (tail !== void 0) if (tail.mode === "text" || tail.failed) state.cards.push(this.freshSegment(notice));
15705
15764
  else {
@@ -15716,6 +15775,7 @@ var TurnStreamer = class {
15716
15775
  state.heartbeatTimer = void 0;
15717
15776
  }
15718
15777
  state.tool = "";
15778
+ state.retry = void 0;
15719
15779
  this.splitOverflow(sessionId, state);
15720
15780
  for (const [index] of state.cards.entries()) this.patchCard(sessionId, state, index, true, false);
15721
15781
  for (const trigger of [state, ...state.mergedTriggers]) this.swapReaction(trigger, failure !== void 0);
@@ -15896,8 +15956,24 @@ const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 2e4;
15896
15956
  * optional context block.
15897
15957
  */
15898
15958
  const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3e3;
15899
- /** Conventional credential reference holding the log-platform analyze API bearer token. */
15900
- 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
+ }
15901
15977
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
15902
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");
15903
15979
  const configSchema = z.object({
@@ -16227,28 +16303,20 @@ const configSchema = z.object({
16227
16303
  * this window fails closed with an explicit error; a host *timed* wait owns
16228
16304
  * its own deadline and is left to the host signal instead.
16229
16305
  */
16230
- questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS),
16231
- /**
16232
- * Switch for the log-platform analyze API (`POST /open-api/logalyze`
16233
- * on the DSH Web server). Off (default) answers 404; read per request, so
16234
- * toggling it needs no reconnect. Needs a host with a Web server.
16235
- */
16236
- analyzeEnabled: z.boolean().default(false),
16237
- /**
16238
- * Credential reference holding the analyze API bearer token (defaults to
16239
- * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
16240
- * applies to the next request; an unresolvable token refuses every request.
16241
- */
16242
- analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF)
16306
+ questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS)
16243
16307
  });
16244
16308
  /**
16245
16309
  * DSH loader-facing schema. The bundle patch inserts this plugin without a
16246
16310
  * `config` object; the loader validates that row with `undefined` before the
16247
16311
  * user's patch layer supplies values. Accept that absence and fill the same
16248
16312
  * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
16249
- * 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.
16250
16315
  */
16251
- const pluginConfigSchema = configSchema.prefault({});
16316
+ const pluginConfigSchema = configSchema.extend({
16317
+ analyzeEnabled: z.unknown().optional(),
16318
+ analyzeToken: z.unknown().optional()
16319
+ }).prefault({});
16252
16320
  /**
16253
16321
  * Validate the raw loader config. Values stay credential *references*; the
16254
16322
  * secrets themselves resolve through `ctx.credentials` at startup.
@@ -16385,7 +16453,6 @@ const EDITABLE_KEYS = [
16385
16453
  "feishuTools.wikiRead",
16386
16454
  "feishuTools.wikiWrite",
16387
16455
  "wikiRoots",
16388
- "analyzeEnabled",
16389
16456
  "loggerLevel"
16390
16457
  ];
16391
16458
  /**
@@ -16513,11 +16580,6 @@ const PANEL_COPY = {
16513
16580
  reconnectDone: "已重启飞书连接",
16514
16581
  loading: "加载中…",
16515
16582
  loadFailed: "设置面板加载失败",
16516
- analyzeTokenLabel: "访问令牌",
16517
- analyzeTokenSummary: "请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求",
16518
- analyzeTokenPlaceholder: "填写新令牌(建议 32 位以上随机串)",
16519
- analyzeTokenSaved: "令牌已保存,下一次请求生效",
16520
- analyzeTokenRemoved: "令牌已移除,接口将拒绝所有请求",
16521
16583
  overlayTitle: "Web 覆盖",
16522
16584
  overlayPath: "覆盖文件",
16523
16585
  overlayCount: "{n} 项配置由面板覆盖",
@@ -16786,6 +16848,8 @@ var OverlayStore = class {
16786
16848
  this.logger?.warn?.("dsh-channels: Web 覆盖配置不是合法 JSON(%s),按未覆盖处理:%s", this.file, String(error));
16787
16849
  return {};
16788
16850
  }
16851
+ const moved = movedAnalyzeKeysOf(raw);
16852
+ if (moved.length > 0) this.logger?.warn?.(MOVED_ANALYZE_NOTICE, `${this.file} 中的 ${moved.join("、")}`);
16789
16853
  const parsed = parseOverlay(raw);
16790
16854
  if (!parsed.ok) {
16791
16855
  this.logger?.warn?.("dsh-channels: Web 覆盖配置含非法字段(%s),按未覆盖处理:%s", this.file, parsed.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
@@ -16864,7 +16928,7 @@ async function openFeishuDomain(ctx) {
16864
16928
  const accessUsersRaw = domain.table("access_users");
16865
16929
  const accessRequestsRaw = domain.table("access_requests");
16866
16930
  const accessWikiUsersRaw = domain.table("access_wiki_users");
16867
- const analyzeRequestsRaw = domain.table("analyze_requests");
16931
+ const topicLaunchesRaw = domain.table("analyze_requests");
16868
16932
  const sessionOriginsRaw = domain.table("session_origins");
16869
16933
  return {
16870
16934
  routes: {
@@ -16915,10 +16979,10 @@ async function openFeishuDomain(ctx) {
16915
16979
  delete: (key) => accessWikiUsersRaw.delete(key),
16916
16980
  entries: () => accessWikiUsersRaw.entries()
16917
16981
  },
16918
- analyzeRequests: {
16919
- get: (key) => analyzeRequestsRaw.get(key),
16920
- put: (key, value) => analyzeRequestsRaw.put(key, value),
16921
- entries: () => analyzeRequestsRaw.entries()
16982
+ topicLaunches: {
16983
+ get: (key) => topicLaunchesRaw.get(key),
16984
+ put: (key, value) => topicLaunchesRaw.put(key, value),
16985
+ entries: () => topicLaunchesRaw.entries()
16922
16986
  },
16923
16987
  sessionOrigins: {
16924
16988
  get: (key) => sessionOriginsRaw.get(key),
@@ -16998,8 +17062,8 @@ var FeishuService = class {
16998
17062
  messageReader = new MessageReader({ channel: () => this.channel });
16999
17063
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
17000
17064
  inbound;
17001
- /** Log-platform analyze API business side (requests → shared analysis topics). */
17002
- analyzeLauncher;
17065
+ /** The `feishuTopics` service (other plugins' launches → shared topics). */
17066
+ topicLauncher;
17003
17067
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
17004
17068
  sessions;
17005
17069
  /**
@@ -17254,7 +17318,7 @@ var FeishuService = class {
17254
17318
  handleSlash: (msg, line) => this.handleSlashMessage(msg, line)
17255
17319
  }
17256
17320
  });
17257
- this.analyzeLauncher = new AnalyzeLauncher({
17321
+ this.topicLauncher = new TopicLauncher({
17258
17322
  ctx: this.ctx,
17259
17323
  config: this.config,
17260
17324
  started: () => this.started,
@@ -17266,8 +17330,7 @@ var FeishuService = class {
17266
17330
  streamer: () => this.streamer,
17267
17331
  queue: this.routeOperations,
17268
17332
  reserveTopic: (threadId) => this.inbound.reserveTopic(threadId),
17269
- mayAnalyzeIn: (chatId) => this.accessController.mayAnalyzeIn(chatId),
17270
- resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => void 0)
17333
+ mayLaunchIn: (chatId) => this.accessController.mayLaunchIn(chatId)
17271
17334
  });
17272
17335
  }
17273
17336
  /** Effective inbound attachment backend (matches the inbound router). */
@@ -17548,9 +17611,9 @@ var FeishuService = class {
17548
17611
  this.restarting = false;
17549
17612
  }
17550
17613
  }
17551
- /** The analyze API's business side, for the Web route. */
17552
- analyzeHandler() {
17553
- return this.analyzeLauncher;
17614
+ /** The `feishuTopics` service, provided by `apply()`. */
17615
+ topics() {
17616
+ return this.topicLauncher;
17554
17617
  }
17555
17618
  isRunning() {
17556
17619
  return this.started;
@@ -18096,15 +18159,10 @@ var FeishuService = class {
18096
18159
  };
18097
18160
  }
18098
18161
  };
18099
- const [appId, appSecret, analyzeToken] = await Promise.all([
18100
- read(this.config.appId),
18101
- read(this.config.appSecret),
18102
- read(this.config.analyzeToken)
18103
- ]);
18162
+ const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)]);
18104
18163
  return {
18105
18164
  appId,
18106
- appSecret,
18107
- analyzeToken
18165
+ appSecret
18108
18166
  };
18109
18167
  }
18110
18168
  /** The host credentials seam, when mounted. */
@@ -18140,7 +18198,6 @@ var FeishuService = class {
18140
18198
  switch (field) {
18141
18199
  case "appId": return this.config.appId;
18142
18200
  case "appSecret": return this.config.appSecret;
18143
- case "analyzeToken": return this.config.analyzeToken;
18144
18201
  }
18145
18202
  }
18146
18203
  /** Wire events and open the long connection. */
@@ -18239,8 +18296,8 @@ var FeishuService = class {
18239
18296
  await channel.connect();
18240
18297
  this.runScopeCheck(channel, appId, ownerLookup);
18241
18298
  if (this.config.slashCommandPanel) this.runSlashCommandSync(channel);
18242
- await this.analyzeLauncher.markInterrupted();
18243
- this.analyzeLauncher.open();
18299
+ await this.topicLauncher.markInterrupted();
18300
+ this.topicLauncher.open();
18244
18301
  this.repair.repairAll().catch((error) => {
18245
18302
  this.ctx.logger?.warn?.("dsh-channels: inflight repair failed: %s", String(error));
18246
18303
  });
@@ -18275,8 +18332,8 @@ var FeishuService = class {
18275
18332
  /** Tear down: close the connection and dispose all live agents. */
18276
18333
  async shutdown() {
18277
18334
  this.started = false;
18278
- this.analyzeLauncher.close();
18279
- await this.analyzeLauncher.drain();
18335
+ this.topicLauncher.close();
18336
+ await this.topicLauncher.drain();
18280
18337
  for (const controller of this.commandControllers) controller.abort();
18281
18338
  await Promise.allSettled([...this.commandOperations]);
18282
18339
  this.commandControllers.clear();
@@ -19490,7 +19547,7 @@ const MAX_BODY_BYTES = 64 * 1024;
19490
19547
  function messageOf$2(error) {
19491
19548
  return error instanceof Error ? error.message : String(error);
19492
19549
  }
19493
- var HttpError$1 = class extends Error {
19550
+ var HttpError = class extends Error {
19494
19551
  status;
19495
19552
  constructor(status, message) {
19496
19553
  super(message);
@@ -19508,17 +19565,17 @@ function json(status, payload) {
19508
19565
  }
19509
19566
  /** Parse a JSON-object body; malformed bodies throw {@link HttpError} (415/413/400). */
19510
19567
  async function readJsonObject(request) {
19511
- 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");
19512
19569
  const text = await request.text();
19513
- if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError$1(413, "请求体过大");
19514
- 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, "请求体为空");
19515
19572
  let body;
19516
19573
  try {
19517
19574
  body = JSON.parse(text);
19518
19575
  } catch {
19519
- throw new HttpError$1(400, "请求体不是合法 JSON");
19576
+ throw new HttpError(400, "请求体不是合法 JSON");
19520
19577
  }
19521
- 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 对象");
19522
19579
  return body;
19523
19580
  }
19524
19581
  /** Distinct non-empty entries of one comma-separated id query parameter. */
@@ -19536,7 +19593,7 @@ function answered(handler) {
19536
19593
  try {
19537
19594
  return await handler(request);
19538
19595
  } catch (error) {
19539
- if (error instanceof HttpError$1) return json(error.status, {
19596
+ if (error instanceof HttpError) return json(error.status, {
19540
19597
  ok: false,
19541
19598
  error: error.message
19542
19599
  });
@@ -19602,14 +19659,13 @@ function registerSettingsRoutes(connection, surface) {
19602
19659
  const values = {};
19603
19660
  if (payload.appId !== void 0) values.appId = payload.appId;
19604
19661
  if (payload.appSecret !== void 0) values.appSecret = payload.appSecret;
19605
- if (payload.analyzeToken !== void 0) values.analyzeToken = payload.analyzeToken;
19606
19662
  return result(await surface.saveCredentials(values));
19607
19663
  }),
19608
19664
  post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
19609
19665
  const field = (await readJsonObject(request)).field;
19610
- if (field !== "appId" && field !== "appSecret" && field !== "analyzeToken") return json(400, {
19666
+ if (field !== "appId" && field !== "appSecret") return json(400, {
19611
19667
  ok: false,
19612
- error: "field 必须是 appId、appSecret 或 analyzeToken"
19668
+ error: "field 必须是 appId 或 appSecret"
19613
19669
  });
19614
19670
  return result(await surface.clearCredential(field));
19615
19671
  }),
@@ -19673,160 +19729,6 @@ function mountSettingsRoutes(ctx, surface) {
19673
19729
  attach(ctx);
19674
19730
  }
19675
19731
  //#endregion
19676
- //#region src/analyze/server.ts
19677
- /**
19678
- * The analyze API's HTTP route on the DSH Web server.
19679
- *
19680
- * Registers the exact route `POST /open-api/logalyze` (see
19681
- * `./contract.ts`) on the host `webServer`; hosts without one (headless) have
19682
- * no analyze API. This module owns the transport-level checks, in order:
19683
- * path, the `analyzeEnabled` switch, method, bearer token, `Idempotency-Key`,
19684
- * media type, body size, and JSON validation; everything after validation
19685
- * belongs to the {@link AnalyzeHandler}. Bodies, tokens, and prompts are never
19686
- * logged.
19687
- *
19688
- * @module dsh-channels/analyze/server
19689
- */
19690
- var HttpError = class extends Error {
19691
- status;
19692
- constructor(status, message) {
19693
- super(message);
19694
- this.status = status;
19695
- }
19696
- };
19697
- function send(res, response) {
19698
- const body = JSON.stringify(response.body);
19699
- res.writeHead(response.status, {
19700
- "content-type": "application/json; charset=utf-8",
19701
- "cache-control": "no-store",
19702
- "content-length": Buffer.byteLength(body)
19703
- });
19704
- res.end(body);
19705
- }
19706
- function refuse(res, status, error, headers = {}) {
19707
- for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
19708
- send(res, {
19709
- status,
19710
- body: {
19711
- ok: false,
19712
- error
19713
- }
19714
- });
19715
- }
19716
- /** Constant-time token comparison over fixed-length digests. */
19717
- function tokenMatches(presented, expected) {
19718
- return timingSafeEqual(createHash("sha256").update(presented).digest(), createHash("sha256").update(expected).digest());
19719
- }
19720
- /** The bearer credential of one request, or undefined. */
19721
- function bearerOf(req) {
19722
- const header = req.headers.authorization;
19723
- if (header === void 0) return void 0;
19724
- return /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)?.[1];
19725
- }
19726
- /** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
19727
- async function readBody(req) {
19728
- const declared = Number(req.headers["content-length"]);
19729
- if (Number.isFinite(declared) && declared > 1048576) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
19730
- const chunks = [];
19731
- let size = 0;
19732
- for await (const chunk of req) {
19733
- const buffer = chunk;
19734
- size += buffer.length;
19735
- if (size > 1048576) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
19736
- chunks.push(buffer);
19737
- }
19738
- return Buffer.concat(chunks).toString("utf8");
19739
- }
19740
- /**
19741
- * Handle one HTTP request against the analyze contract.
19742
- * @param req - the incoming request.
19743
- * @param res - its response.
19744
- * @param handler - the business side.
19745
- */
19746
- async function handleAnalyzeHttp(req, res, handler) {
19747
- if (new URL(req.url ?? "/", "http://analyze.invalid").pathname !== "/open-api/logalyze") {
19748
- refuse(res, 404, "未知路径");
19749
- return;
19750
- }
19751
- if (!handler.enabled()) {
19752
- refuse(res, 404, "日志分析接口未开启,请在设置面板「高级 → 日志分析接口」打开开关");
19753
- return;
19754
- }
19755
- if ((req.method ?? "GET").toUpperCase() !== "POST") {
19756
- refuse(res, 405, "只接受 POST", { allow: "POST" });
19757
- return;
19758
- }
19759
- const expected = await handler.token();
19760
- if (expected === void 0 || expected === "") {
19761
- refuse(res, 503, "分析接口未配置访问令牌,请联系部署者配置 analyzeToken 引用的凭据");
19762
- return;
19763
- }
19764
- const presented = bearerOf(req);
19765
- if (presented === void 0 || !tokenMatches(presented, expected)) {
19766
- refuse(res, 401, "访问令牌无效", { "www-authenticate": "Bearer" });
19767
- return;
19768
- }
19769
- const idempotencyKey = req.headers["idempotency-key"];
19770
- if (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
19771
- refuse(res, 400, "缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)");
19772
- return;
19773
- }
19774
- if ((req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") {
19775
- refuse(res, 415, "Content-Type 必须是 application/json");
19776
- return;
19777
- }
19778
- let raw;
19779
- try {
19780
- raw = JSON.parse(await readBody(req));
19781
- } catch (error) {
19782
- if (error instanceof HttpError) {
19783
- refuse(res, error.status, error.message);
19784
- return;
19785
- }
19786
- refuse(res, 400, "请求体不是合法的 JSON");
19787
- return;
19788
- }
19789
- const parsed = parseAnalyzeRequest(raw);
19790
- if (!parsed.ok) {
19791
- refuse(res, 400, parsed.error);
19792
- return;
19793
- }
19794
- send(res, await handler.analyze(parsed.value, idempotencyKey));
19795
- }
19796
- /**
19797
- * Register the analyze route on the host Web server once it is available.
19798
- * Requests skip the DSH login gate on purpose: the bearer token is this API's
19799
- * only credential.
19800
- * @param ctx - the plugin context.
19801
- * @param handler - the business side.
19802
- * @param warn - diagnostics sink for unexpected handler failures.
19803
- */
19804
- function mountAnalyzeRoute(ctx, handler, warn) {
19805
- const attach = (webCtx) => {
19806
- const server = webCtx.get?.("webServer");
19807
- if (server === void 0) return;
19808
- webCtx.effect(() => server.register({
19809
- kind: "exact",
19810
- path: ANALYZE_ROUTE,
19811
- handler: async (req, res) => {
19812
- try {
19813
- await handleAnalyzeHttp(req, res, handler);
19814
- } catch (error) {
19815
- warn("dsh-channels: analyze request failed: %s", String(error));
19816
- if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`);
19817
- else res.destroy();
19818
- }
19819
- }
19820
- }), "dsh-channels: analyze route");
19821
- };
19822
- const inject = ctx.inject;
19823
- if (typeof inject === "function") {
19824
- inject.call(ctx, ["webServer"], attach);
19825
- return;
19826
- }
19827
- attach(ctx);
19828
- }
19829
- //#endregion
19830
19732
  //#region src/settings/surface.ts
19831
19733
  /**
19832
19734
  * Web 设置面板的 Host 视图模型:把 `FeishuService` 的运行时事实、覆盖层与
@@ -19841,11 +19743,7 @@ function mountAnalyzeRoute(ctx, handler, warn) {
19841
19743
  */
19842
19744
  const RESTART_SET = new Set(RESTART_KEYS);
19843
19745
  /** Every credential slot the panel writes, in form order. */
19844
- const CREDENTIAL_FIELDS = [
19845
- "appId",
19846
- "appSecret",
19847
- "analyzeToken"
19848
- ];
19746
+ const CREDENTIAL_FIELDS = ["appId", "appSecret"];
19849
19747
  /** Optional fields: an empty string in the panel clears the override. */
19850
19748
  const OPTIONAL_TEXT_KEYS = /* @__PURE__ */ new Set([
19851
19749
  "provider",
@@ -19867,8 +19765,7 @@ const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
19867
19765
  "feishuTools.topicMessages",
19868
19766
  "feishuTools.sendFile",
19869
19767
  "feishuTools.wikiRead",
19870
- "feishuTools.wikiWrite",
19871
- "analyzeEnabled"
19768
+ "feishuTools.wikiWrite"
19872
19769
  ]);
19873
19770
  /** Editable keys rendered as `string-list` (one input per entry). */
19874
19771
  const STRING_LIST_FIELDS = { wikiRoots: {
@@ -20309,12 +20206,6 @@ const FIELD_META = {
20309
20206
  placeholder: "https://<租户>.feishu.cn/wiki/space/<space_id>",
20310
20207
  wide: true
20311
20208
  },
20312
- analyzeEnabled: {
20313
- label: "日志分析接口",
20314
- group: "analyze",
20315
- summary: "关闭时接口返回 404",
20316
- help: "接口挂在 DSH Web 端口上:POST /open-api/logalyze,请求需带 Authorization: Bearer <访问令牌>。只有带 Web 的 profile 才有这个接口。改后立即生效,不需要重连。"
20317
- },
20318
20209
  loggerLevel: {
20319
20210
  label: "SDK 日志级别",
20320
20211
  group: "logging"
@@ -20665,10 +20556,6 @@ var SettingsSurface = class {
20665
20556
  appSecret: {
20666
20557
  configured: false,
20667
20558
  writable: false
20668
- },
20669
- analyzeToken: {
20670
- configured: false,
20671
- writable: false
20672
20559
  }
20673
20560
  };
20674
20561
  try {
@@ -20684,8 +20571,7 @@ var SettingsSurface = class {
20684
20571
  });
20685
20572
  const credentials = {
20686
20573
  appId: toWire(effective.appId, credentialStates.appId),
20687
- appSecret: toWire(effective.appSecret, credentialStates.appSecret),
20688
- analyzeToken: toWire(effective.analyzeToken, credentialStates.analyzeToken)
20574
+ appSecret: toWire(effective.appSecret, credentialStates.appSecret)
20689
20575
  };
20690
20576
  let access;
20691
20577
  try {
@@ -20861,11 +20747,6 @@ var SettingsSurface = class {
20861
20747
  error: messageOf$1(error)
20862
20748
  };
20863
20749
  }
20864
- if (writes.every((write) => write.field === "analyzeToken")) return {
20865
- ok: true,
20866
- panel: await this.panel(),
20867
- reconnect: "not-needed"
20868
- };
20869
20750
  return this.finishCredentialChange("save");
20870
20751
  }
20871
20752
  /** Remove the stored value behind one credential reference. */
@@ -20884,11 +20765,6 @@ var SettingsSurface = class {
20884
20765
  error: messageOf$1(error)
20885
20766
  };
20886
20767
  }
20887
- if (field === "analyzeToken") return {
20888
- ok: true,
20889
- panel: await this.panel(),
20890
- reconnect: "not-needed"
20891
- };
20892
20768
  return this.finishCredentialChange("clear");
20893
20769
  }
20894
20770
  /**
@@ -21415,7 +21291,9 @@ function apply(ctx, config) {
21415
21291
  ctx.logger?.warn?.(message, ...args);
21416
21292
  };
21417
21293
  mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn), createSettingsDirectoryPicker(ctx)));
21418
- 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("、")}`);
21419
21297
  ctx.effect(() => {
21420
21298
  service.boot().catch((error) => {
21421
21299
  ctx.logger?.error?.("dsh-channels failed to boot: %s", String(error));
@@ -21425,6 +21303,6 @@ function apply(ctx, config) {
21425
21303
  }, "dsh-channels");
21426
21304
  }
21427
21305
  //#endregion
21428
- 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 };
21429
21307
 
21430
21308
  //# sourceMappingURL=index.js.map