@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/src/index.ts CHANGED
@@ -17,13 +17,13 @@
17
17
 
18
18
  import type { HostContext } from './dsh-types.ts'
19
19
  import { FeishuService } from './service.ts'
20
- import { pluginConfigSchema, resolveConfig } from './config.ts'
20
+ import { MOVED_ANALYZE_NOTICE, movedAnalyzeKeysOf, pluginConfigSchema, resolveConfig } from './config.ts'
21
21
  import type { FeishuConfig } from './config.ts'
22
22
  import { defaultChannelFactory } from './feishu-channel.ts'
23
23
  import type { ChannelFactory } from './feishu-channel.ts'
24
24
  import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
25
25
  import { mountSettingsRoutes } from './settings/routes.ts'
26
- import { mountAnalyzeRoute } from './analyze/server.ts'
26
+ import { FEISHU_TOPICS_SERVICE } from './topics/launcher.ts'
27
27
  import { SettingsSurface } from './settings/surface.ts'
28
28
  import { createHostCatalog } from './settings/catalog.ts'
29
29
  import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
@@ -40,6 +40,7 @@ export type * from './session-export.ts'
40
40
  export type * from './stats.ts'
41
41
  export type * from './questions.ts'
42
42
  export type * from './settings/contract.ts'
43
+ export type * from './topics/contract.ts'
43
44
  export * from './access/index.ts'
44
45
  export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
45
46
  export { CATALOG_ROUTE, CLEAR_CONFIG_ROUTE, CLEAR_CREDENTIAL_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, SAVE_CONFIG_ROUTE, SAVE_CREDENTIALS_ROUTE, STATUS_ROUTE } from './settings/contract.ts'
@@ -54,7 +55,7 @@ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './sett
54
55
  export { mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
55
56
  export type { HostConnection, HostFetchRoute } from './settings/routes.ts'
56
57
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
57
- export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
58
+ export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, buildTopicRootCard, MAX_QUESTION_OPTIONS } from './cards.ts'
58
59
  export { ApprovalAnswerer } from './approvals.ts'
59
60
  export { QuestionAnswerer } from './questions.ts'
60
61
  export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
@@ -63,6 +64,8 @@ export { buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, bu
63
64
  export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, serializeLogicalSession } from './session-export.ts'
64
65
  export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
65
66
  export { FeishuService } from './service.ts'
67
+ export { FEISHU_TOPICS_SERVICE, FEISHU_TOPICS_VERSION, TopicLauncher } from './topics/launcher.ts'
68
+ export type { TopicLauncherDeps } from './topics/launcher.ts'
66
69
  export { TurnStreamer } from './streamer.ts'
67
70
  export { ensureWorkspace, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
68
71
  export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
@@ -125,7 +128,11 @@ export function apply(ctx: HostContext, config?: unknown): void {
125
128
  createHostCatalog(ctx, warn),
126
129
  createSettingsDirectoryPicker(ctx),
127
130
  ))
128
- mountAnalyzeRoute(ctx, service.analyzeHandler(), warn)
131
+ // Other plugins (观象's analyze API) start group-shared topics through this
132
+ // service; it answers `not-connected` until the Feishu connection is up.
133
+ ctx.provide?.(FEISHU_TOPICS_SERVICE, service.topics())
134
+ const moved = movedAnalyzeKeysOf(config)
135
+ if (moved.length > 0) warn(MOVED_ANALYZE_NOTICE, `cordis.yml 中的 ${moved.join('、')}`)
129
136
  ctx.effect(() => {
130
137
  // Boot loads the Web overlay and connects only when the Feishu application
131
138
  // credentials are complete; an incomplete configuration or a failed
@@ -273,7 +273,7 @@ export class InboundRouter {
273
273
  */
274
274
  private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
275
275
  /**
276
- * topic id → reservation by an external starter (the analyze launcher) that
276
+ * topic id → reservation by an external starter (the `feishuTopics` launcher) that
277
277
  * opened the topic but has not published its route yet. A reply in the topic
278
278
  * waits for the release and then routes against the published record, or
279
279
  * falls through as an unbound topic when the start failed.
package/src/service.ts CHANGED
@@ -52,12 +52,11 @@ import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
52
52
  import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
53
53
  import type {
54
54
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
55
- AnalyzeRequestRecord, ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
56
- ProcessedRecord, RawDomainHandle, RouteRecord, SessionOriginRecord,
55
+ ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
56
+ ProcessedRecord, RawDomainHandle, RouteRecord, SessionOriginRecord, TopicLaunchRecord,
57
57
  } from './domain.ts'
58
58
  import { RouteStore } from './session/routes.ts'
59
- import { AnalyzeLauncher } from './analyze/launcher.ts'
60
- import type { AnalyzeHandler } from './analyze/server.ts'
59
+ import { TopicLauncher } from './topics/launcher.ts'
61
60
  import { InflightRepair } from './session/repair.ts'
62
61
  import { SessionManager } from './session/manager.ts'
63
62
  import type { LiveAgent, RenewCommit } from './session/manager.ts'
@@ -168,7 +167,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
168
167
  const accessUsersRaw = domain.table('access_users')
169
168
  const accessRequestsRaw = domain.table('access_requests')
170
169
  const accessWikiUsersRaw = domain.table('access_wiki_users')
171
- const analyzeRequestsRaw = domain.table('analyze_requests')
170
+ const topicLaunchesRaw = domain.table('analyze_requests')
172
171
  const sessionOriginsRaw = domain.table('session_origins')
173
172
  return {
174
173
  routes: {
@@ -219,10 +218,10 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
219
218
  delete: key => accessWikiUsersRaw.delete(key),
220
219
  entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
221
220
  },
222
- analyzeRequests: {
223
- get: key => analyzeRequestsRaw.get(key) as AnalyzeRequestRecord | undefined,
224
- put: (key, value) => analyzeRequestsRaw.put(key, value),
225
- entries: () => analyzeRequestsRaw.entries() as IterableIterator<[string, AnalyzeRequestRecord]>,
221
+ topicLaunches: {
222
+ get: key => topicLaunchesRaw.get(key) as TopicLaunchRecord | undefined,
223
+ put: (key, value) => topicLaunchesRaw.put(key, value),
224
+ entries: () => topicLaunchesRaw.entries() as IterableIterator<[string, TopicLaunchRecord]>,
226
225
  },
227
226
  sessionOrigins: {
228
227
  get: key => sessionOriginsRaw.get(key) as SessionOriginRecord | undefined,
@@ -310,8 +309,8 @@ export class FeishuService {
310
309
  private readonly messageReader = new MessageReader({ channel: () => this.channel })
311
310
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
312
311
  private readonly inbound: InboundRouter
313
- /** Log-platform analyze API business side (requests → shared analysis topics). */
314
- private readonly analyzeLauncher: AnalyzeLauncher
312
+ /** The `feishuTopics` service (other plugins' launches → shared topics). */
313
+ private readonly topicLauncher: TopicLauncher
315
314
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
316
315
  private readonly sessions: SessionManager
317
316
  /**
@@ -591,7 +590,7 @@ export class FeishuService {
591
590
  handleSlash: (msg, line) => this.handleSlashMessage(msg, line),
592
591
  },
593
592
  })
594
- this.analyzeLauncher = new AnalyzeLauncher({
593
+ this.topicLauncher = new TopicLauncher({
595
594
  ctx: this.ctx,
596
595
  config: this.config,
597
596
  started: () => this.started,
@@ -603,8 +602,7 @@ export class FeishuService {
603
602
  streamer: () => this.streamer,
604
603
  queue: this.routeOperations,
605
604
  reserveTopic: threadId => this.inbound.reserveTopic(threadId),
606
- mayAnalyzeIn: chatId => this.accessController.mayAnalyzeIn(chatId),
607
- resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => undefined),
605
+ mayLaunchIn: chatId => this.accessController.mayLaunchIn(chatId),
608
606
  })
609
607
  }
610
608
 
@@ -975,8 +973,8 @@ export class FeishuService {
975
973
  }
976
974
  }
977
975
 
978
- /** The analyze API's business side, for the Web route. */
979
- analyzeHandler(): AnalyzeHandler { return this.analyzeLauncher }
976
+ /** The `feishuTopics` service, provided by `apply()`. */
977
+ topics(): TopicLauncher { return this.topicLauncher }
980
978
  isRunning(): boolean { return this.started }
981
979
  isRestarting(): boolean { return this.restarting }
982
980
  /** True while the panel's explicit connect action is in flight. */
@@ -1475,10 +1473,8 @@ export class FeishuService {
1475
1473
  return { configured: false, writable: false }
1476
1474
  }
1477
1475
  }
1478
- const [appId, appSecret, analyzeToken] = await Promise.all([
1479
- read(this.config.appId), read(this.config.appSecret), read(this.config.analyzeToken),
1480
- ])
1481
- return { appId, appSecret, analyzeToken }
1476
+ const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)])
1477
+ return { appId, appSecret }
1482
1478
  }
1483
1479
 
1484
1480
  /** The host credentials seam, when mounted. */
@@ -1521,7 +1517,6 @@ export class FeishuService {
1521
1517
  switch (field) {
1522
1518
  case 'appId': return this.config.appId
1523
1519
  case 'appSecret': return this.config.appSecret
1524
- case 'analyzeToken': return this.config.analyzeToken
1525
1520
  }
1526
1521
  }
1527
1522
 
@@ -1658,8 +1653,8 @@ export class FeishuService {
1658
1653
  void this.runScopeCheck(channel, appId, ownerLookup)
1659
1654
  if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
1660
1655
  // Before admission opens, so every `running` record left is a previous instance's.
1661
- await this.analyzeLauncher.markInterrupted()
1662
- this.analyzeLauncher.open()
1656
+ await this.topicLauncher.markInterrupted()
1657
+ this.topicLauncher.open()
1663
1658
  // Repair cards a previous instance left mid-flight (best-effort, background).
1664
1659
  void this.repair.repairAll().catch((error: unknown) => {
1665
1660
  this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
@@ -1723,8 +1718,8 @@ export class FeishuService {
1723
1718
  // before their Agent/channel/storage dependencies are disposed, while
1724
1719
  // already queued route operations observe `started === false` and retire.
1725
1720
  this.started = false
1726
- this.analyzeLauncher.close()
1727
- await this.analyzeLauncher.drain()
1721
+ this.topicLauncher.close()
1722
+ await this.topicLauncher.drain()
1728
1723
  for (const controller of this.commandControllers) controller.abort()
1729
1724
  await Promise.allSettled([...this.commandOperations])
1730
1725
  this.commandControllers.clear()
@@ -69,13 +69,13 @@ export interface SessionOriginWire {
69
69
  chatName?: string
70
70
  /** 群头像 URL(群聊且能读取群信息时)。 */
71
71
  chatAvatarUrl?: string
72
- /** 发起人 user_id(日志平台分析话题为请求人)。 */
72
+ /** 发起人 user_id(群共享话题为调用方指定的发起人)。 */
73
73
  senderUserId: string
74
74
  /** 发起人姓名(通讯录或群成员列表能查到时)。 */
75
75
  senderName?: string
76
76
  /** 发起人头像 URL(通讯录能查到时)。 */
77
77
  senderAvatarUrl?: string
78
- /** 日志平台分析话题:群内成员都可以继续。 */
78
+ /** 群共享话题(经 `feishuTopics` 发起):群内成员都可以继续。 */
79
79
  shared: boolean
80
80
  /** Epoch ms when the Session was bound to its Feishu route. */
81
81
  createdAt: number
@@ -187,8 +187,6 @@ export const EDITABLE_KEYS = [
187
187
  'feishuTools.wikiRead',
188
188
  'feishuTools.wikiWrite',
189
189
  'wikiRoots',
190
- // 日志平台分析接口(访问令牌是凭据引用 analyzeToken,值写在 DSH 凭据里)
191
- 'analyzeEnabled',
192
190
  // 日志
193
191
  'loggerLevel',
194
192
  ] as const
@@ -242,7 +240,6 @@ export type FieldGroup =
242
240
  | 'limits'
243
241
  | 'access'
244
242
  | 'wiki'
245
- | 'analyze'
246
243
  | 'logging'
247
244
 
248
245
  /** One selectable value rendered by the generic form. */
@@ -355,11 +352,8 @@ export interface CredentialRefWire {
355
352
  source?: string
356
353
  }
357
354
 
358
- /**
359
- * The credential slots the panel may write: the Feishu application pair
360
- * (resolved on connect) and the analyze API token (resolved per request).
361
- */
362
- export type CredentialField = 'appId' | 'appSecret' | 'analyzeToken'
355
+ /** The credential slots the panel may write: the Feishu application pair (resolved on connect). */
356
+ export type CredentialField = 'appId' | 'appSecret'
363
357
 
364
358
  /** Official-SDK WebSocket lifecycle snapshot (structural copy, no SDK import on the wire). */
365
359
  export interface ConnectionWire {
@@ -466,7 +460,6 @@ export interface PanelConfigWire {
466
460
  credentials: {
467
461
  appId: CredentialRefWire
468
462
  appSecret: CredentialRefWire
469
- analyzeToken: CredentialRefWire
470
463
  }
471
464
  }
472
465
 
@@ -602,7 +595,6 @@ export type ClearConfigResponse =
602
595
  export interface SaveCredentialsRequest {
603
596
  appId?: string
604
597
  appSecret?: string
605
- analyzeToken?: string
606
598
  }
607
599
 
608
600
  /** `POST /api/dsh-channels.clearCredential` body. */
@@ -615,8 +607,7 @@ export interface ClearCredentialRequest {
615
607
  *
616
608
  * - `auto`: the channel was stopped and connected immediately after the save.
617
609
  * - `pending`: the channel is running; reconnect for the value to take effect.
618
- * - `not-needed`: removed while stopped, or only the analyze API token
619
- * changed (it applies to the next request), nothing to reconnect.
610
+ * - `not-needed`: removed while stopped, nothing to reconnect.
620
611
  * - `unconfigured`: saved, but the other credential of the pair is still
621
612
  * missing — the plugin keeps waiting (no connection attempt) until it is
622
613
  * configured.
@@ -666,7 +657,7 @@ export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]
666
657
  attachments: ['attachments', 'tools', 'limits'],
667
658
  access: ['access'],
668
659
  wiki: ['wiki'],
669
- advanced: ['analyze', 'logging'],
660
+ advanced: ['logging'],
670
661
  }
671
662
 
672
663
  /** Section heading for one field group inside its tab. */
@@ -681,7 +672,6 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
681
672
  limits: '限额与超时',
682
673
  access: '准入',
683
674
  wiki: '知识库工具',
684
- analyze: '日志分析接口',
685
675
  logging: '日志',
686
676
  }
687
677
 
@@ -808,11 +798,6 @@ export const PANEL_COPY = {
808
798
  loading: '加载中…',
809
799
  loadFailed: '设置面板加载失败',
810
800
  // ---- advanced -----------------------------------------------------------
811
- analyzeTokenLabel: '访问令牌',
812
- analyzeTokenSummary: '请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求',
813
- analyzeTokenPlaceholder: '填写新令牌(建议 32 位以上随机串)',
814
- analyzeTokenSaved: '令牌已保存,下一次请求生效',
815
- analyzeTokenRemoved: '令牌已移除,接口将拒绝所有请求',
816
801
  overlayTitle: 'Web 覆盖',
817
802
  overlayPath: '覆盖文件',
818
803
  overlayCount: '{n} 项配置由面板覆盖',
@@ -16,7 +16,7 @@ import { promises as fs } from 'node:fs'
16
16
  import { homedir } from 'node:os'
17
17
  import { dirname, join } from 'node:path'
18
18
  import type { z } from 'zod'
19
- import { configSchema } from '../config.ts'
19
+ import { configSchema, MOVED_ANALYZE_NOTICE, movedAnalyzeKeysOf } from '../config.ts'
20
20
  import type { FeishuConfig } from '../config.ts'
21
21
  import { normalizeWorkspaceBaseDir } from '../workspaces.ts'
22
22
  import { EDITABLE_KEYS } from './contract.ts'
@@ -245,6 +245,8 @@ export class OverlayStore {
245
245
  this.logger?.warn?.('dsh-channels: Web 覆盖配置不是合法 JSON(%s),按未覆盖处理:%s', this.file, String(error))
246
246
  return {}
247
247
  }
248
+ const moved = movedAnalyzeKeysOf(raw)
249
+ if (moved.length > 0) this.logger?.warn?.(MOVED_ANALYZE_NOTICE, `${this.file} 中的 ${moved.join('、')}`)
248
250
  const parsed = parseOverlay(raw)
249
251
  if (!parsed.ok) {
250
252
  this.logger?.warn?.(
@@ -175,13 +175,12 @@ export function registerSettingsRoutes(connection: HostConnection, surface: Sett
175
175
  const values: SaveCredentialsRequest = {}
176
176
  if (payload.appId !== undefined) values.appId = payload.appId as string
177
177
  if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
178
- if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
179
178
  return result(await surface.saveCredentials(values))
180
179
  }),
181
180
  post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
182
181
  const field = (await readJsonObject(request)).field
183
- if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
184
- return json(400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
182
+ if (field !== 'appId' && field !== 'appSecret') {
183
+ return json(400, { ok: false, error: 'field 必须是 appId 或 appSecret' })
185
184
  }
186
185
  return result(await surface.clearCredential(field as CredentialField))
187
186
  }),
@@ -135,7 +135,7 @@ export interface CredentialInfo {
135
135
  const RESTART_SET = new Set<string>(RESTART_KEYS)
136
136
 
137
137
  /** Every credential slot the panel writes, in form order. */
138
- const CREDENTIAL_FIELDS: readonly CredentialField[] = ['appId', 'appSecret', 'analyzeToken']
138
+ const CREDENTIAL_FIELDS: readonly CredentialField[] = ['appId', 'appSecret']
139
139
 
140
140
  /** Optional fields: an empty string in the panel clears the override. */
141
141
  const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
@@ -160,7 +160,6 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
160
160
  'feishuTools.sendFile',
161
161
  'feishuTools.wikiRead',
162
162
  'feishuTools.wikiWrite',
163
- 'analyzeEnabled',
164
163
  ])
165
164
 
166
165
  /** Bounds and entry format of one `string-list` field. */
@@ -438,12 +437,6 @@ const FIELD_META: Record<EditableKey, FieldMeta> = {
438
437
  placeholder: 'https://<租户>.feishu.cn/wiki/space/<space_id>',
439
438
  wide: true,
440
439
  },
441
- analyzeEnabled: {
442
- label: '日志分析接口',
443
- group: 'analyze',
444
- summary: '关闭时接口返回 404',
445
- help: '接口挂在 DSH Web 端口上:POST /api/dsh-channels.logalyze,请求需带 Authorization: Bearer <访问令牌>。只有带 Web 的 profile 才有这个接口。改后立即生效,不需要重连。',
446
- },
447
440
  loggerLevel: { label: 'SDK 日志级别', group: 'logging' },
448
441
  }
449
442
 
@@ -785,7 +778,6 @@ export class SettingsSurface {
785
778
  let credentialStates: Record<CredentialField, CredentialInfo> = {
786
779
  appId: { configured: false, writable: false },
787
780
  appSecret: { configured: false, writable: false },
788
- analyzeToken: { configured: false, writable: false },
789
781
  }
790
782
  try {
791
783
  credentialStates = await this.target.credentialStates()
@@ -801,7 +793,6 @@ export class SettingsSurface {
801
793
  const credentials: PanelConfigWire['credentials'] = {
802
794
  appId: toWire(effective.appId, credentialStates.appId),
803
795
  appSecret: toWire(effective.appSecret, credentialStates.appSecret),
804
- analyzeToken: toWire(effective.analyzeToken, credentialStates.analyzeToken),
805
796
  }
806
797
 
807
798
  // The access snapshot comes from in-memory maps, but a broken seam must
@@ -963,9 +954,6 @@ export class SettingsSurface {
963
954
  } catch (error) {
964
955
  return { ok: false, error: messageOf(error) }
965
956
  }
966
- if (writes.every(write => write.field === 'analyzeToken')) {
967
- return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
968
- }
969
957
  return this.finishCredentialChange('save')
970
958
  }
971
959
 
@@ -981,7 +969,6 @@ export class SettingsSurface {
981
969
  } catch (error) {
982
970
  return { ok: false, error: messageOf(error) }
983
971
  }
984
- if (field === 'analyzeToken') return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
985
972
  return this.finishCredentialChange('clear')
986
973
  }
987
974
 
package/src/streamer.ts CHANGED
@@ -245,6 +245,14 @@ interface TurnState {
245
245
  * durable model history, so the card must drop them again.
246
246
  */
247
247
  attempts: Map<string, AttemptMark>
248
+ /**
249
+ * The model-request retry `dsh-llm-retry` scheduled for the current step
250
+ * (`llm/retry`), shown on the tool line until the retried request produces
251
+ * output. A retry stays inside the open turn: only `turn/end` settles it.
252
+ */
253
+ retry: RetryActivity | undefined
254
+ /** Retries scheduled during this turn (reported on a failed settled card). */
255
+ retries: number
248
256
  /** The owner clicked stop; the button renders disabled until the turn settles. */
249
257
  stopRequested: boolean
250
258
  /** A non-owner clicked stop; the owner-only notice renders under the button. */
@@ -253,6 +261,20 @@ interface TurnState {
253
261
  queuedDenied: boolean
254
262
  }
255
263
 
264
+ /** One scheduled model-request retry (`llm/retry` → `llm/retry-started`). */
265
+ interface RetryActivity {
266
+ /** 1-based retry number. */
267
+ retry: number
268
+ /** Normal-mode budget; undefined in always mode. */
269
+ maxRetries: number | undefined
270
+ /** Failure code of the attempt that triggered the retry. */
271
+ code: string
272
+ /** Unix ms when the backoff wait ends. */
273
+ until: number
274
+ /** Set by `llm/retry-started`: the retried request is in flight. */
275
+ started: boolean
276
+ }
277
+
256
278
  /** Card/body lengths captured at one stream attempt's `start` frame. */
257
279
  interface AttemptMark {
258
280
  cardCount: number
@@ -426,6 +448,8 @@ export class TurnStreamer {
426
448
  toolKind: '',
427
449
  heartbeatTimer: undefined,
428
450
  attempts: new Map(),
451
+ retry: undefined,
452
+ retries: 0,
429
453
  stopRequested: false,
430
454
  stopDenied: false,
431
455
  queuedDenied: false,
@@ -638,6 +662,10 @@ export class TurnStreamer {
638
662
  }
639
663
  case 'assistant/message': {
640
664
  if (state.turn === undefined) state.turn = event.data.turn
665
+ if (state.retry !== undefined) {
666
+ state.retry = undefined
667
+ this.markDirty(session.id, state)
668
+ }
641
669
  if (event.data.usage !== undefined) {
642
670
  state.lastUsage = {
643
671
  usage: event.data.usage,
@@ -726,6 +754,28 @@ export class TurnStreamer {
726
754
  }
727
755
  break
728
756
  }
757
+ case 'llm/retry': {
758
+ // The failed attempt ended the step's request, not the turn: the loop
759
+ // re-runs the step after the backoff, so the card stays live.
760
+ if (state.turn === undefined) state.turn = event.data.turn
761
+ state.retry = {
762
+ retry: event.data.retry,
763
+ maxRetries: event.data.mode === 'normal' ? event.data.maxRetries : undefined,
764
+ code: event.data.failure.code,
765
+ until: Date.now() + Math.max(0, event.data.delayMs),
766
+ started: false,
767
+ }
768
+ state.retries++
769
+ this.markDirty(session.id, state)
770
+ break
771
+ }
772
+ case 'llm/retry-started': {
773
+ if (state.retry !== undefined && state.retry.retry === event.data.retry) {
774
+ state.retry.started = true
775
+ this.markDirty(session.id, state)
776
+ }
777
+ break
778
+ }
729
779
  case 'turn/end': {
730
780
  // The ended turn belongs to the front state: assign it if a turn was
731
781
  // rejected/emptied before any step started.
@@ -762,6 +812,12 @@ export class TurnStreamer {
762
812
  }
763
813
  case 'chunk': {
764
814
  const chunk = frame.chunk
815
+ if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')
816
+ && chunk.text !== '' && state.retry !== undefined) {
817
+ // The retried request is producing output again.
818
+ state.retry = undefined
819
+ this.markDirty(session.id, state)
820
+ }
765
821
  if (chunk.type === 'text-delta') {
766
822
  if (chunk.text !== '') this.closeReasoning(state)
767
823
  const last = state.cards[state.cards.length - 1]
@@ -784,7 +840,13 @@ export class TurnStreamer {
784
840
  this.closeReasoning(state)
785
841
  const mark = state.attempts.get(frame.attemptId)
786
842
  state.attempts.delete(frame.attemptId)
787
- if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
843
+ // A failed request attempt settles as `assistant/attempt`: like an
844
+ // abandoned one, its partial output is not model-visible history, and
845
+ // a retry streams the step again from scratch — keeping it would
846
+ // repeat the reply on the card.
847
+ const discarded = frame.outcome.kind === 'abandoned'
848
+ || frame.outcome.eventType === 'assistant/attempt'
849
+ if (discarded && mark !== undefined) {
788
850
  this.rollbackAttempt(state, mark)
789
851
  this.markDirty(session.id, state)
790
852
  }
@@ -812,11 +874,13 @@ export class TurnStreamer {
812
874
  }
813
875
 
814
876
  /**
815
- * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
877
+ * Drop one discarded attempt's deltas. The attempt's text is a contiguous
816
878
  * suffix of the logical body: segments created during the attempt are
817
879
  * removed, the segment that was last at `start` is truncated, and the
818
880
  * reasoning tail is cut. Already-delivered plain-text segments cannot be
819
- * retracted and stop the removal.
881
+ * retracted and stop the removal; segment cards already sent (or being
882
+ * sent) cannot be withdrawn, so they are emptied and kept. The newest
883
+ * remaining card is reopened so a retry streams into it again.
820
884
  */
821
885
  private rollbackAttempt(state: TurnState, mark: AttemptMark): void {
822
886
  const lastIndex = mark.cardCount - 1
@@ -824,6 +888,11 @@ export class TurnStreamer {
824
888
  const card = state.cards[index]
825
889
  if (card === undefined) continue
826
890
  if (card.mode === 'text' && card.messageId !== undefined) break
891
+ if (card.messageId !== undefined || card.sending) {
892
+ card.body = ''
893
+ card.dirty = true
894
+ continue
895
+ }
827
896
  state.cards.splice(index, 1)
828
897
  }
829
898
  const tail = state.cards[lastIndex]
@@ -833,8 +902,16 @@ export class TurnStreamer {
833
902
  tail.body = tail.body.slice(0, length)
834
903
  tail.dirty = true
835
904
  }
905
+ const last = state.cards[state.cards.length - 1]
906
+ if (last !== undefined) {
907
+ if ((last.mode === 'text' && last.messageId !== undefined) || last.failed) {
908
+ state.cards.push(this.freshSegment(''))
909
+ } else {
910
+ last.frozen = false
911
+ }
912
+ }
836
913
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
837
- // The abandoned attempt's reasoning time is not part of the visible turn.
914
+ // The discarded attempt's reasoning time is not part of the visible turn.
838
915
  state.reasoningMs = mark.reasoningMs
839
916
  state.reasoningSince = mark.reasoningSince
840
917
  }
@@ -1049,6 +1126,13 @@ export class TurnStreamer {
1049
1126
  if (state.awaitingApproval.length > 0) {
1050
1127
  return clipActivity(`⏳ 等待你审批:${[...new Set(state.awaitingApproval)].join('、')}`)
1051
1128
  }
1129
+ const retry = state.retry
1130
+ if (retry !== undefined) {
1131
+ const count = retry.maxRetries === undefined ? `${retry.retry}` : `${retry.retry}/${retry.maxRetries}`
1132
+ if (retry.started) return clipActivity(`🔁 模型请求重试中(第 ${count} 次)`)
1133
+ const wait = Math.max(0, Math.ceil((retry.until - now) / 1_000))
1134
+ return clipActivity(`⚠️ 模型请求失败(${retry.code}),${wait} 秒后第 ${count} 次重试`)
1135
+ }
1052
1136
  const tool = state.toolActivity
1053
1137
  if (tool === undefined) return ''
1054
1138
  const prefix = tool.name === 'subagent'
@@ -1295,7 +1379,8 @@ export class TurnStreamer {
1295
1379
  this.closeReasoning(state)
1296
1380
  if (failure !== undefined) {
1297
1381
  const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
1298
- const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
1382
+ const retried = state.retries === 0 ? '' : `\n\n已自动重试 ${state.retries} 次`
1383
+ const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}${retried}`
1299
1384
  const tail = state.cards[state.cards.length - 1]
1300
1385
  if (tail !== undefined) {
1301
1386
  if (tail.mode === 'text' || tail.failed) {
@@ -1322,6 +1407,7 @@ export class TurnStreamer {
1322
1407
  state.heartbeatTimer = undefined
1323
1408
  }
1324
1409
  state.tool = ''
1410
+ state.retry = undefined
1325
1411
  // Freeze the tail and spawn any remaining segment cards.
1326
1412
  this.splitOverflow(sessionId, state)
1327
1413