@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.4

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 日志分析接口令牌编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
3
+ * 「已配置 / 未配置」,不回显已存值;保存或移除后下一次接口请求即生效,不触发重连。
4
+ *
5
+ * @module dsh-channels/client/panel/analyze-token-card
6
+ */
7
+
8
+ import { PANEL_COPY } from '../../settings/contract.ts'
9
+ import type { PanelWire } from '../../settings/contract.ts'
10
+ import type { AnalyzeTokenView } from './shared.tsx'
11
+
12
+ export function AnalyzeTokenCard(props: {
13
+ panel: PanelWire
14
+ token: AnalyzeTokenView
15
+ }): JSX.Element {
16
+ const { panel, token } = props
17
+ const state = panel.config.credentials.analyzeToken
18
+ const disabled = token.busy !== null || token.locked || !state.writable
19
+ const issue = token.issues.find(item => item.key === 'analyzeToken')
20
+ const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
21
+ return (
22
+ <section className="dsh-channels-cfg__card">
23
+ <div className="dsh-channels-cfg__cardHead">
24
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.analyzeTokenCard}</span>
25
+ <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'muted'}`}>
26
+ {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
27
+ </span>
28
+ </div>
29
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.analyzeTokenCardHelp}</span>
30
+ <div className="dsh-channels-cfg__field">
31
+ <span className="dsh-channels-cfg__label">{PANEL_COPY.analyzeTokenLabel}</span>
32
+ <div className="dsh-channels-cfg__cred">
33
+ <input
34
+ type="password"
35
+ className={inputClass}
36
+ value={token.value}
37
+ placeholder={PANEL_COPY.analyzeTokenPlaceholder}
38
+ autoComplete="new-password"
39
+ aria-label={PANEL_COPY.analyzeTokenLabel}
40
+ disabled={disabled}
41
+ onChange={event => token.onChange(event.target.value)}
42
+ />
43
+ {state.configured
44
+ ? (
45
+ <button type="button" className="dsh-channels-cfg__btn" disabled={disabled} onClick={token.onClear}>
46
+ {token.busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
47
+ </button>
48
+ )
49
+ : null}
50
+ </div>
51
+ <span className="dsh-channels-cfg__help">
52
+ {PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
53
+ {state.source === undefined ? '' : ` · ${state.source}`}
54
+ </span>
55
+ {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
56
+ </div>
57
+ {state.writable ? null : <span className="dsh-channels-cfg__fieldError">{PANEL_COPY.credentialReadOnly}</span>}
58
+ <div className="dsh-channels-cfg__footer">
59
+ <button
60
+ type="button"
61
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
62
+ onClick={token.onSave}
63
+ disabled={disabled || token.value.trim() === ''}
64
+ >
65
+ {token.busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveAnalyzeToken}
66
+ </button>
67
+ </div>
68
+ </section>
69
+ )
70
+ }
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
14
  import type { KeyboardEvent } from 'react'
15
15
  import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
16
16
  import type {
17
+ AccessMutationResponse,
17
18
  AccessWire,
18
19
  CatalogWire,
19
20
  ChannelStatusWire,
@@ -28,6 +29,7 @@ import type {
28
29
  import {
29
30
  addAccessGroup,
30
31
  addAccessUser,
32
+ addWikiUser,
31
33
  clearConfig,
32
34
  clearCredential,
33
35
  fetchCatalog,
@@ -37,6 +39,7 @@ import {
37
39
  reconnect,
38
40
  removeAccessGroup,
39
41
  removeAccessUser,
42
+ removeWikiUser,
40
43
  resolveAccessRequest,
41
44
  saveConfig,
42
45
  saveCredentials,
@@ -56,12 +59,14 @@ import {
56
59
  validateDraft,
57
60
  } from './shared.tsx'
58
61
  import type {
62
+ AnalyzeTokenView,
59
63
  BusyAction,
60
64
  CredentialsView,
61
65
  FormView,
62
66
  LoadState,
63
67
  Notice,
64
68
  } from './shared.tsx'
69
+ import { WikiTab } from './wiki-tab.tsx'
65
70
  import { WorkspaceTab } from './workspace-tab.tsx'
66
71
 
67
72
  /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
@@ -80,6 +85,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
80
85
  const [pollError, setPollError] = useState<string | null>(null)
81
86
  const [appIdInput, setAppIdInput] = useState('')
82
87
  const [appSecretInput, setAppSecretInput] = useState('')
88
+ const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
83
89
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
84
90
  const [catalogBusy, setCatalogBusy] = useState(false)
85
91
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
@@ -106,6 +112,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
106
112
  setDraft({})
107
113
  setAppIdInput('')
108
114
  setAppSecretInput('')
115
+ setAnalyzeTokenInput('')
109
116
  } catch (error) {
110
117
  setLoad({ phase: 'error', message: messageOf(error) })
111
118
  }
@@ -453,18 +460,24 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
453
460
  }
454
461
  }, [applyAccess])
455
462
 
456
- const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
463
+ /** Add one user to a whitelist; resolves true when the input should clear. */
464
+ const addUser = useCallback(async (
465
+ add: (userId: string, name: string) => Promise<AccessMutationResponse>,
466
+ userId: string,
467
+ name: string,
468
+ notice: string,
469
+ ): Promise<boolean> => {
457
470
  setBusy('access')
458
471
  setAccessError(null)
459
472
  setAccessNotice(null)
460
473
  try {
461
- const result = await addAccessUser(userId, name)
474
+ const result = await add(userId, name)
462
475
  if (!result.ok) {
463
476
  setAccessError(result.error)
464
477
  return false
465
478
  }
466
479
  applyAccess(result.access)
467
- setAccessNotice(PANEL_COPY.accessUserAdded)
480
+ setAccessNotice(notice)
468
481
  return true
469
482
  } catch (error) {
470
483
  setAccessError(messageOf(error))
@@ -474,18 +487,22 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
474
487
  }
475
488
  }, [applyAccess])
476
489
 
477
- const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
490
+ const removeUser = useCallback(async (
491
+ remove: (userId: string) => Promise<AccessMutationResponse>,
492
+ userId: string,
493
+ notice: string,
494
+ ): Promise<void> => {
478
495
  setBusy('access')
479
496
  setAccessError(null)
480
497
  setAccessNotice(null)
481
498
  try {
482
- const result = await removeAccessUser(userId)
499
+ const result = await remove(userId)
483
500
  if (!result.ok) {
484
501
  setAccessError(result.error)
485
502
  return
486
503
  }
487
504
  applyAccess(result.access)
488
- setAccessNotice(PANEL_COPY.accessUserRemoved)
505
+ setAccessNotice(notice)
489
506
  } catch (error) {
490
507
  setAccessError(messageOf(error))
491
508
  } finally {
@@ -552,6 +569,28 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
552
569
  }
553
570
  }, [appIdInput, appSecretInput])
554
571
 
572
+ /** Save the analyze API token; it applies to the next request, no reconnect. */
573
+ const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
574
+ if (analyzeTokenInput.trim() === '') return
575
+ setBusy('credentials')
576
+ setNotice(null)
577
+ try {
578
+ const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
579
+ if (!result.ok) {
580
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
581
+ return
582
+ }
583
+ setLoad({ phase: 'ready', panel: result.panel })
584
+ setStatus(result.panel.status)
585
+ setAnalyzeTokenInput('')
586
+ setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
587
+ } catch (error) {
588
+ setNotice({ kind: 'error', text: messageOf(error) })
589
+ } finally {
590
+ setBusy(null)
591
+ }
592
+ }, [analyzeTokenInput])
593
+
555
594
  const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
556
595
  const confirmed = typeof window.confirm === 'function'
557
596
  ? window.confirm(PANEL_COPY.credentialRemoveConfirm)
@@ -569,7 +608,9 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
569
608
  setStatus(result.panel.status)
570
609
  setNotice({
571
610
  kind: 'ok',
572
- text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
611
+ text: field === 'analyzeToken'
612
+ ? PANEL_COPY.analyzeTokenRemoved
613
+ : result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
573
614
  })
574
615
  } catch (error) {
575
616
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -628,6 +669,16 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
628
669
  onClear: (field: CredentialField) => { void onClearCredential(field) },
629
670
  }), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
630
671
 
672
+ const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
673
+ value: analyzeTokenInput,
674
+ issues,
675
+ busy,
676
+ locked: hostBusy,
677
+ onChange: setAnalyzeTokenInput,
678
+ onSave: () => { void onSaveAnalyzeToken() },
679
+ onClear: () => { void onClearCredential('analyzeToken') },
680
+ }), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
681
+
631
682
  if (load.phase === 'loading') {
632
683
  return (
633
684
  <div className="dsh-channels-cfg">
@@ -680,7 +731,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
680
731
  />
681
732
  )
682
733
  : null}
683
- {activeTab === 'sessions' ? <SessionsTab form={form} /> : null}
734
+ {activeTab === 'sessions' ? <SessionsTab form={form} analyzeToken={analyzeToken} /> : null}
684
735
  {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
685
736
  {activeTab === 'workspace'
686
737
  ? (
@@ -695,12 +746,23 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
695
746
  accessError={accessError}
696
747
  onAddGroup={onAddAccessGroup}
697
748
  onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
698
- onAddUser={onAddAccessUser}
699
- onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
749
+ onAddUser={(userId, name) => addUser(addAccessUser, userId, name, PANEL_COPY.accessUserAdded)}
750
+ onRemoveUser={userId => { void removeUser(removeAccessUser, userId, PANEL_COPY.accessUserRemoved) }}
700
751
  onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
701
752
  />
702
753
  )
703
754
  : null}
755
+ {activeTab === 'wiki'
756
+ ? (
757
+ <WikiTab
758
+ form={form}
759
+ accessNotice={accessNotice}
760
+ accessError={accessError}
761
+ onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
762
+ onRemoveUser={userId => { void removeUser(removeWikiUser, userId, PANEL_COPY.wikiUserRemoved) }}
763
+ />
764
+ )
765
+ : null}
704
766
  {activeTab === 'advanced'
705
767
  ? <AdvancedTab form={form} onClear={() => { void onClear() }} />
706
768
  : null}
@@ -1,21 +1,24 @@
1
1
  /**
2
2
  * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
3
- * 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
3
+ * 首次引导、权限切换、附件接收、日志分析接口)。表单字段完全由 Host 描述驱动;
4
+ * 日志分析接口令牌是凭据,单独一张卡片。
4
5
  *
5
6
  * @module dsh-channels/client/panel/sessions-tab
6
7
  */
7
8
 
8
9
  import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import { AnalyzeTokenCard } from './analyze-token-card.tsx'
9
11
  import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
10
- import type { FormView } from './shared.tsx'
12
+ import type { AnalyzeTokenView, FormView } from './shared.tsx'
11
13
 
12
- export function SessionsTab(props: { form: FormView }): JSX.Element {
13
- const { form } = props
14
+ export function SessionsTab(props: { form: FormView; analyzeToken: AnalyzeTokenView }): JSX.Element {
15
+ const { form, analyzeToken } = props
14
16
  return (
15
17
  <div className="dsh-channels-cfg__tabBody">
16
18
  <TabHelp text={PANEL_COPY.sessionsTabHelp} />
17
19
  <FieldsCard form={form} groups={TAB_FIELD_GROUPS.sessions} />
18
20
  <SaveBar form={form} tab="sessions" />
21
+ <AnalyzeTokenCard panel={form.panel} token={analyzeToken} />
19
22
  </div>
20
23
  )
21
24
  }
@@ -182,6 +182,18 @@ export interface CredentialsView {
182
182
  onClear: (field: CredentialField) => void
183
183
  }
184
184
 
185
+ /** Analyze API token editor data owned by the page shell, rendered by the sessions tab. */
186
+ export interface AnalyzeTokenView {
187
+ value: string
188
+ issues: FieldIssueWire[]
189
+ busy: BusyAction | null
190
+ /** Host-side lifecycle busy (connecting/restarting): block writes. */
191
+ locked: boolean
192
+ onChange: (value: string) => void
193
+ onSave: () => void
194
+ onClear: () => void
195
+ }
196
+
185
197
  /** Everything a field-bearing tab needs from the page shell's single draft. */
186
198
  export interface FormView {
187
199
  panel: PanelWire
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 「知识库」tab:知识库工具字段表单(读取/写入开关、知识库范围)+ 知识库
3
+ * 白名单卡。表单保存走全局 draft,白名单增删是独立即时 mutation。
4
+ *
5
+ * @module dsh-channels/client/panel/wiki-tab
6
+ */
7
+
8
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
+ import { UserWhitelist } from './access-card.tsx'
10
+ import { EmptyState, FieldsCard, SaveBar, TabHelp } from './shared.tsx'
11
+ import type { FormView } from './shared.tsx'
12
+
13
+ export interface WikiTabProps {
14
+ form: FormView
15
+ accessNotice: string | null
16
+ accessError: string | null
17
+ onAddUser: (userId: string, name: string) => Promise<boolean>
18
+ onRemoveUser: (userId: string) => void
19
+ }
20
+
21
+ export function WikiTab(props: WikiTabProps): JSX.Element {
22
+ const { form, accessNotice, accessError, onAddUser, onRemoveUser } = props
23
+ const { access } = form.panel
24
+ const disabled = form.busy !== null || form.locked || !access.loaded
25
+ return (
26
+ <div className="dsh-channels-cfg__tabBody">
27
+ <TabHelp text={PANEL_COPY.wikiTabHelp} />
28
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.wiki} />
29
+ <SaveBar form={form} tab="wiki" />
30
+ <section className="dsh-channels-cfg__card">
31
+ <div className="dsh-channels-cfg__cardHead">
32
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.wikiUsersCard}</span>
33
+ </div>
34
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.wikiUsersHelp}</span>
35
+ {!access.loaded
36
+ ? <EmptyState text={PANEL_COPY.accessNotLoaded} />
37
+ : (
38
+ <UserWhitelist
39
+ title={PANEL_COPY.wikiUsersCard}
40
+ empty={PANEL_COPY.wikiUsersEmpty}
41
+ addLabel={PANEL_COPY.accessAddUser}
42
+ users={access.wikiUsers}
43
+ disabled={disabled}
44
+ working={form.busy === 'access'}
45
+ onAdd={onAddUser}
46
+ onRemove={onRemoveUser}
47
+ />
48
+ )}
49
+ {accessNotice === null
50
+ ? null
51
+ : (
52
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok" role="status" aria-live="polite">
53
+ <span>{accessNotice}</span>
54
+ </div>
55
+ )}
56
+ {accessError === null
57
+ ? null
58
+ : <span className="dsh-channels-cfg__fieldError" role="alert">{accessError}</span>}
59
+ </section>
60
+ </div>
61
+ )
62
+ }
package/src/config.ts CHANGED
@@ -80,6 +80,12 @@ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
80
80
  */
81
81
  export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
82
82
 
83
+ /** Conventional credential reference holding the log-platform analyze API bearer token. */
84
+ export const DEFAULT_ANALYZE_TOKEN_REF = 'DSH_CHANNELS_ANALYZE_TOKEN'
85
+
86
+ /** `host:port` listen address of the analyze API (`[v6]:port` for IPv6 literals). */
87
+ export const ANALYZE_LISTEN_PATTERN = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+):(?:[1-9][0-9]{0,4})$/u
88
+
83
89
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
84
90
  const credentialRefSchema = z.string().regex(
85
91
  CREDENTIAL_REF_PATTERN,
@@ -298,16 +304,18 @@ export const configSchema = z.object({
298
304
  * `feishu_wiki_base_read` so the model can browse the wiki nodes under
299
305
  * `wikiRoots` and read docx documents, spreadsheets, and Bases there.
300
306
  * Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
301
- * `docx:document:readonly`), `sheets:spreadsheet` (or a read-only
302
- * spreadsheet scope), and `bitable:app` (or a read-only Base scope).
307
+ * `docx:document:readonly`), `sheets:spreadsheet:read`, and
308
+ * `base:table:read` + `base:field:read` + `base:record:read` (the
309
+ * classic `sheets:spreadsheet` / `bitable:app` grants do not apply).
303
310
  * - `wikiWrite` (default off): register `feishu_wiki_create`,
304
311
  * `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
305
312
  * `feishu_wiki_base_edit` so the model can create docx documents,
306
313
  * spreadsheets, and Bases under `wikiRoots`, edit documents and
307
314
  * spreadsheets this app owns, and change records of Bases this app owns
308
315
  * (other Bases accept new records only). Needs `wiki:wiki`,
309
- * `docx:document`, `sheets:spreadsheet`, and `bitable:app` (or the
310
- * matching write-only scopes).
316
+ * `docx:document`, `sheets:spreadsheet:write_only`, and
317
+ * `base:record:create` / `base:record:update` / `base:record:delete`,
318
+ * plus the read scopes above.
311
319
  *
312
320
  * The wiki tools act with the app identity and are mounted only while
313
321
  * `wikiRoots` is non-empty. Each call is limited to topics whose owner is
@@ -409,6 +417,24 @@ export const configSchema = z.object({
409
417
  * its own deadline and is left to the host signal instead.
410
418
  */
411
419
  questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
420
+ /**
421
+ * Listen address (`host:port`, e.g. `0.0.0.0:3090`) of the log-platform
422
+ * analyze API: a dedicated HTTP listener serving only
423
+ * `POST /dsh-channels/analyze`, separate from the DSH Web port. Unset
424
+ * (default) keeps the API off. The listener runs while the Feishu connection
425
+ * is up and is rebound on reconnect.
426
+ */
427
+ analyzeListen: z.string().trim().regex(
428
+ ANALYZE_LISTEN_PATTERN,
429
+ 'must be host:port, e.g. 0.0.0.0:3090 or 127.0.0.1:3090',
430
+ ).refine(value => Number(value.slice(value.lastIndexOf(':') + 1)) <= 65_535, 'port must be 1-65535')
431
+ .optional(),
432
+ /**
433
+ * Credential reference holding the analyze API bearer token (defaults to
434
+ * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
435
+ * applies to the next request; an unresolvable token refuses every request.
436
+ */
437
+ analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF),
412
438
  })
413
439
 
414
440
  export type FeishuConfig = z.infer<typeof configSchema>
package/src/domain.ts CHANGED
@@ -61,6 +61,12 @@ export const routeRecordSchema = z.object({
61
61
  lastActivityAt: z.number().int().nonnegative(),
62
62
  /** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
63
63
  sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
64
+ /**
65
+ * Group-shared route (log-platform analysis topics): every member admitted
66
+ * in `chatId` may continue the Session and decide its cards, not only
67
+ * `senderUserId` (who then names the requester). Absent = owner-only.
68
+ */
69
+ shared: z.literal(true).optional(),
64
70
  })
65
71
 
66
72
  export type RouteRecord = z.infer<typeof routeRecordSchema>
@@ -281,6 +287,40 @@ export const processedRecordSchema = z.object({
281
287
 
282
288
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
283
289
 
290
+ /**
291
+ * One accepted analyze API request, keyed by its `Idempotency-Key`. Kept
292
+ * forever: a retried key returns the stored response instead of posting a
293
+ * second analysis, and a reused key with a different body is refused.
294
+ */
295
+ export const analyzeRequestRecordSchema = z.object({
296
+ /** The request's `Idempotency-Key` (also the table key). */
297
+ key: z.string().min(1),
298
+ /** SHA-256 (hex) of the canonical request body. */
299
+ bodyHash: z.string().min(1),
300
+ /** Target group chat. */
301
+ chatId: z.string().min(1),
302
+ /** Root card message id (the analysis topic's root). */
303
+ messageId: z.string().min(1),
304
+ /** Epoch ms when the request was accepted. */
305
+ acceptedAt: z.number().int().nonnegative(),
306
+ /** What the root card shows, kept so the card can be redrawn after a restart. */
307
+ subject: z.object({
308
+ requesterUserId: z.string(),
309
+ triggeredAt: z.string(),
310
+ app: z.string(),
311
+ errorCode: z.string(),
312
+ logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
313
+ }),
314
+ /**
315
+ * Root card status. `running` until the first turn settles; a record still
316
+ * `running` when the channel starts belongs to an analysis the previous
317
+ * instance never finished, and becomes `interrupted`.
318
+ */
319
+ status: z.enum(['running', 'done', 'stopped', 'error', 'failed', 'interrupted']),
320
+ })
321
+
322
+ export type AnalyzeRequestRecord = z.infer<typeof analyzeRequestRecordSchema>
323
+
284
324
  export interface FeishuDomainSpecShape {
285
325
  name: 'channels_routes_v2'
286
326
  version: 1
@@ -321,6 +361,10 @@ export interface FeishuDomainSpecShape {
321
361
  valueSchema: typeof accessUserRecordSchema
322
362
  __key?: string
323
363
  }
364
+ analyze_requests: {
365
+ valueSchema: typeof analyzeRequestRecordSchema
366
+ __key?: string
367
+ }
324
368
  }
325
369
  }
326
370
 
@@ -361,6 +405,8 @@ export const feishuDomain: FeishuDomainSpecShape = {
361
405
  // independent grant. Added without a version bump like the other access
362
406
  // tables: a missing table loads empty.
363
407
  access_wiki_users: { valueSchema: accessUserRecordSchema },
408
+ // Analyze API idempotency records; a missing table loads empty.
409
+ analyze_requests: { valueSchema: analyzeRequestRecordSchema },
364
410
  },
365
411
  }
366
412
 
@@ -427,6 +473,13 @@ export interface AccessRequestsTable {
427
473
  entries(): IterableIterator<[string, AccessRequestRecord]>
428
474
  }
429
475
 
476
+ /** Normalized table handle for analyze API requests (key = `Idempotency-Key`). */
477
+ export interface AnalyzeRequestsTable {
478
+ get(key: string): AnalyzeRequestRecord | undefined
479
+ put(key: string, value: AnalyzeRequestRecord): Promise<unknown> | void
480
+ entries(): IterableIterator<[string, AnalyzeRequestRecord]>
481
+ }
482
+
430
483
  /** Normalized domain handle. */
431
484
  export interface FeishuDomainHandle {
432
485
  routes: RoutesTable
@@ -438,6 +491,8 @@ export interface FeishuDomainHandle {
438
491
  accessRequests: AccessRequestsTable
439
492
  /** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
440
493
  accessWikiUsers: AccessUsersTable
494
+ /** Analyze API idempotency records. */
495
+ analyzeRequests: AnalyzeRequestsTable
441
496
  getGlobal(): FeishuDomainGlobal
442
497
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
443
498
  close(): Promise<void>
@@ -91,10 +91,16 @@ const SEND_FILE_REQUIREMENT: ScopeRequirement = {
91
91
  anyOf: ['im:resource', 'im:resource:upload'], level: 'optional', purpose: '上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户',
92
92
  }
93
93
 
94
- /** Spreadsheet and Base read needs, shared by the wiki read and write sets. */
94
+ /**
95
+ * Spreadsheet and Base read needs, shared by the wiki read and write sets. The
96
+ * `sheet_ai/v2` and `base/v3` APIs accept only their fine-grained scopes; the
97
+ * classic `sheets:spreadsheet` / `bitable:app` grants do not satisfy them.
98
+ */
95
99
  const SHEET_BASE_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
96
- { anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:readonly', 'sheets:spreadsheet:read'], level: 'optional', purpose: '读取知识库电子表格(feishu_wiki_sheet_read)' },
97
- { anyOf: ['bitable:app', 'bitable:app:readonly', 'base:record:read'], level: 'optional', purpose: '读取知识库多维表格(feishu_wiki_base_read)' },
100
+ { anyOf: ['sheets:spreadsheet:read'], level: 'optional', purpose: '读取知识库电子表格(feishu_wiki_sheet_read)' },
101
+ { anyOf: ['base:table:read'], level: 'optional', purpose: '列出知识库多维表格的数据表(feishu_wiki_base_read)' },
102
+ { anyOf: ['base:field:read'], level: 'optional', purpose: '读取知识库多维表格的字段(feishu_wiki_base_read)' },
103
+ { anyOf: ['base:record:read'], level: 'optional', purpose: '读取知识库多维表格的记录(feishu_wiki_base_read)' },
98
104
  ]
99
105
 
100
106
  /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
@@ -108,10 +114,10 @@ const WIKI_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
108
114
  const WIKI_WRITE_REQUIREMENTS: readonly ScopeRequirement[] = [
109
115
  { anyOf: ['wiki:wiki'], level: 'optional', purpose: '在知识库中新建节点(feishu_wiki_create)' },
110
116
  { anyOf: ['docx:document'], level: 'optional', purpose: '写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)' },
111
- { anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:write_only'], level: 'optional', purpose: '写入知识库电子表格(feishu_wiki_sheet_edit)' },
112
- { anyOf: ['bitable:app', 'base:record:create'], level: 'optional', purpose: '新增知识库多维表格记录(feishu_wiki_base_edit)' },
113
- { anyOf: ['bitable:app', 'base:record:update'], level: 'optional', purpose: '修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
114
- { anyOf: ['bitable:app', 'base:record:delete'], level: 'optional', purpose: '删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
117
+ { anyOf: ['sheets:spreadsheet:write_only'], level: 'optional', purpose: '写入知识库电子表格(feishu_wiki_sheet_edit)' },
118
+ { anyOf: ['base:record:create'], level: 'optional', purpose: '新增知识库多维表格记录(feishu_wiki_base_edit)' },
119
+ { anyOf: ['base:record:update'], level: 'optional', purpose: '修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
120
+ { anyOf: ['base:record:delete'], level: 'optional', purpose: '删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
115
121
  ...SHEET_BASE_READ_REQUIREMENTS,
116
122
  ]
117
123
 
@@ -105,6 +105,8 @@ export interface AttachmentFetchRoute {
105
105
  readonly chatId: string
106
106
  readonly chatType: 'p2p' | 'group'
107
107
  readonly senderUserId: string
108
+ /** Group-shared route: messages of any sender in the route's topic count as its own. */
109
+ readonly shared?: boolean
108
110
  }
109
111
 
110
112
  /** Why a message became known to one route. */
@@ -811,7 +813,7 @@ export class AttachmentFetchToolRuntime {
811
813
  if (!read.ok) return undefined
812
814
  const container = quoteItemsOf(read.value)
813
815
  if (container === undefined || container.deleted) return undefined
814
- if (container.senderUserId !== call.route.senderUserId) return undefined
816
+ if (call.route.shared !== true && container.senderUserId !== call.route.senderUserId) return undefined
815
817
  if (!messageBelongsToRoute(call.route, container)) return undefined
816
818
  return knownMessageOfItems(read.value)
817
819
  }
package/src/health.ts CHANGED
@@ -55,6 +55,8 @@ export interface HealthSnapshotInput {
55
55
  routeCount: number
56
56
  liveAgentCount: number
57
57
  inflightTurnCount: number
58
+ /** Why the configured analyze listener could not bind; absent when it is bound or not configured. */
59
+ analyzeListenError?: string
58
60
  }
59
61
 
60
62
  export interface HealthSnapshot extends Omit<HealthSnapshotInput, 'fibers' | 'botOpenId'> {
@@ -148,6 +150,7 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
148
150
  || fibers.pending > 0
149
151
  || fibers.loading > 0
150
152
  || fibers.unloading > 0
153
+ || input.analyzeListenError !== undefined
151
154
  ) {
152
155
  level = 'degraded'
153
156
  } else {
@@ -176,6 +179,10 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
176
179
  )
177
180
  }
178
181
 
182
+ if (input.analyzeListenError !== undefined) {
183
+ reasons.push(`日志分析接口监听失败:${input.analyzeListenError}`)
184
+ }
185
+
179
186
  return {
180
187
  level,
181
188
  fibers,
@@ -186,6 +193,7 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
186
193
  routeCount: input.routeCount,
187
194
  liveAgentCount: input.liveAgentCount,
188
195
  inflightTurnCount: input.inflightTurnCount,
196
+ ...(input.analyzeListenError === undefined ? {} : { analyzeListenError: input.analyzeListenError }),
189
197
  inactiveFibers: inactive,
190
198
  inactiveTotal,
191
199
  reasons,
@@ -229,5 +237,9 @@ export function buildHealthCard(snapshot: HealthSnapshot): object {
229
237
  tag: 'markdown',
230
238
  content: `**dsh-channels**\n${snapshot.started ? 'started' : 'stopped'} · 持久化路由 ${snapshot.routeCount} · live Agent ${snapshot.liveAgentCount} · in-flight turn ${snapshot.inflightTurnCount}`,
231
239
  },
240
+ ...(snapshot.analyzeListenError === undefined ? [] : [{
241
+ tag: 'markdown',
242
+ content: `**日志分析接口**\n❌ 监听失败:${snapshot.analyzeListenError}`,
243
+ }]),
232
244
  ])
233
245
  }
package/src/host/agent.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { AttachmentStore } from './attachments.ts'
9
9
  import type { CommandRuntime } from './commands.ts'
10
- import type { Session, SessionId, UserMessage } from './core.ts'
10
+ import type { MessageId, Session, SessionId, UserMessage } from './core.ts'
11
11
 
12
12
  // ---- Agent surface ------------------------------------------------------
13
13
 
@@ -138,9 +138,13 @@ export interface Agent {
138
138
  readonly inbox: {
139
139
  readonly nextTurn: readonly unknown[]
140
140
  readonly nextStep: readonly unknown[]
141
+ /** Remove one still-pending message; false once the driver claimed it. */
142
+ remove(messageId: MessageId): boolean
141
143
  }
142
144
  readonly ctx: AgentContext
143
145
  followup(message: UserMessage): void
146
+ /** Deliver input at the running turn's next step boundary (an idle driver starts a turn). */
147
+ steer(message: UserMessage): void
144
148
  /** Abort the active turn (and pending input unless `keepInbox`). */
145
149
  cancel?(cause: AgentCancelCause, options?: { keepInbox?: boolean }): void
146
150
  }