@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Durable access-control store.
3
+ *
4
+ * Reads come from in-memory maps (seeded once from the plugin's
5
+ * `storageDomain` tables) so admission decisions stay synchronous and cheap;
6
+ * every mutation writes through to the domain first and then updates memory,
7
+ * matching the store patterns used for routes and inflight snapshots.
8
+ *
9
+ * The tables (`access_groups`, `access_users`, `access_requests`) were added
10
+ * without bumping the domain version on purpose: the JSON backend's `single`
11
+ * layout refuses a stored-version mismatch, while unknown/missing tables are
12
+ * simply empty on load (see `docs/access-control-design.md` §4.2).
13
+ *
14
+ * @module dsh-channels/access/store
15
+ */
16
+
17
+ import type {
18
+ AccessGroupRecord,
19
+ AccessRequestRecord,
20
+ AccessUserRecord,
21
+ FeishuDomainHandle,
22
+ } from '../domain.ts'
23
+
24
+ export interface AccessStoreDeps {
25
+ /** The open domain handle, or undefined before start / after shutdown. */
26
+ domain(): FeishuDomainHandle | undefined
27
+ warn(message: string, ...args: unknown[]): void
28
+ }
29
+
30
+ /** Read-only whitespace snapshot handed to the pure policy. */
31
+ export interface AccessStoreSnapshot {
32
+ groups: ReadonlySet<string>
33
+ users: ReadonlySet<string>
34
+ }
35
+
36
+ /** One settled authorization request (audit trail). */
37
+ export type AccessRequestStatus = AccessRequestRecord['status']
38
+
39
+ export class AccessStore {
40
+ private readonly groups = new Map<string, AccessGroupRecord>()
41
+ private readonly users = new Map<string, AccessUserRecord>()
42
+ private readonly requests = new Map<string, AccessRequestRecord>()
43
+ /** Cached key sets so `snapshot()` can stay allocation-free per message. */
44
+ private readonly groupKeys = new Set<string>()
45
+ private readonly userKeys = new Set<string>()
46
+
47
+ constructor(private readonly deps: AccessStoreDeps) {}
48
+
49
+ /** Seed memory from the durable tables (called once after the domain opens). */
50
+ load(): void {
51
+ this.clear()
52
+ const domain = this.deps.domain()
53
+ if (domain === undefined) {
54
+ this.deps.warn('dsh-channels: access tables unavailable; starting with an empty whitelist')
55
+ return
56
+ }
57
+ for (const [key, record] of domain.accessGroups.entries()) {
58
+ this.groups.set(key, record)
59
+ this.groupKeys.add(key)
60
+ }
61
+ for (const [key, record] of domain.accessUsers.entries()) {
62
+ this.users.set(key, record)
63
+ this.userKeys.add(key)
64
+ }
65
+ for (const [key, record] of domain.accessRequests.entries()) {
66
+ this.requests.set(key, record)
67
+ }
68
+ }
69
+
70
+ /** Drop every in-memory record (shutdown). The domain owns durability. */
71
+ clear(): void {
72
+ this.groups.clear()
73
+ this.users.clear()
74
+ this.requests.clear()
75
+ this.groupKeys.clear()
76
+ this.userKeys.clear()
77
+ }
78
+
79
+ snapshot(): AccessStoreSnapshot {
80
+ return { groups: this.groupKeys, users: this.userKeys }
81
+ }
82
+
83
+ hasGroup(chatId: string): boolean {
84
+ return this.groupKeys.has(chatId)
85
+ }
86
+
87
+ hasUser(openId: string): boolean {
88
+ return this.userKeys.has(openId)
89
+ }
90
+
91
+ getGroup(chatId: string): AccessGroupRecord | undefined {
92
+ return this.groups.get(chatId)
93
+ }
94
+
95
+ getUser(openId: string): AccessUserRecord | undefined {
96
+ return this.users.get(openId)
97
+ }
98
+
99
+ /** Every authorized group, oldest first. */
100
+ groupRecords(): AccessGroupRecord[] {
101
+ return [...this.groups.values()].sort((left, right) => left.addedAt - right.addedAt)
102
+ }
103
+
104
+ /** Every authorized user, oldest first. */
105
+ userRecords(): AccessUserRecord[] {
106
+ return [...this.users.values()].sort((left, right) => left.addedAt - right.addedAt)
107
+ }
108
+
109
+ async addGroup(record: AccessGroupRecord): Promise<AccessGroupRecord> {
110
+ await this.requireDomain('add access group').accessGroups.put(record.chatId, record)
111
+ this.groups.set(record.chatId, record)
112
+ this.groupKeys.add(record.chatId)
113
+ return record
114
+ }
115
+
116
+ async removeGroup(chatId: string): Promise<boolean> {
117
+ if (!this.groups.has(chatId)) return false
118
+ await this.requireDomain('remove access group').accessGroups.delete(chatId)
119
+ this.groups.delete(chatId)
120
+ this.groupKeys.delete(chatId)
121
+ return true
122
+ }
123
+
124
+ async addUser(record: AccessUserRecord): Promise<AccessUserRecord> {
125
+ await this.requireDomain('add access user').accessUsers.put(record.openId, record)
126
+ this.users.set(record.openId, record)
127
+ this.userKeys.add(record.openId)
128
+ return record
129
+ }
130
+
131
+ async removeUser(openId: string): Promise<boolean> {
132
+ if (!this.users.has(openId)) return false
133
+ await this.requireDomain('remove access user').accessUsers.delete(openId)
134
+ this.users.delete(openId)
135
+ this.userKeys.delete(openId)
136
+ return true
137
+ }
138
+
139
+ /** Every authorization request, including settled ones (audit). */
140
+ requestRecords(): AccessRequestRecord[] {
141
+ return [...this.requests.values()].sort((left, right) => left.createdAt - right.createdAt)
142
+ }
143
+
144
+ /**
145
+ * Pending, not-yet-expired requests. Expired-but-unsettled records are
146
+ * excluded here; `expireRequests()` is the sweep that persists the terminal
147
+ * state (and lets the card surface patch the DM card).
148
+ */
149
+ pendingRequests(now = Date.now()): AccessRequestRecord[] {
150
+ return this.requestRecords().filter(
151
+ record => record.status === 'pending' && record.expiresAt > now,
152
+ )
153
+ }
154
+
155
+ getRequest(token: string): AccessRequestRecord | undefined {
156
+ return this.requests.get(token)
157
+ }
158
+
159
+ /** The current pending request for one target, if any. */
160
+ pendingRequestFor(kind: AccessRequestRecord['kind'], targetId: string): AccessRequestRecord | undefined {
161
+ for (const record of this.requests.values()) {
162
+ if (record.kind === kind && record.targetId === targetId && record.status === 'pending') {
163
+ return record
164
+ }
165
+ }
166
+ return undefined
167
+ }
168
+
169
+ /**
170
+ * True when the same target was denied recently — used to keep a rejected
171
+ * request from immediately re-notifying the administrator.
172
+ */
173
+ recentlyDenied(
174
+ kind: AccessRequestRecord['kind'],
175
+ targetId: string,
176
+ windowMs: number,
177
+ now = Date.now(),
178
+ ): boolean {
179
+ const cutoff = now - windowMs
180
+ for (const record of this.requests.values()) {
181
+ if (record.kind === kind && record.targetId === targetId
182
+ && record.status === 'denied' && record.createdAt >= cutoff) {
183
+ return true
184
+ }
185
+ }
186
+ return false
187
+ }
188
+
189
+ async putRequest(record: AccessRequestRecord): Promise<AccessRequestRecord> {
190
+ await this.requireDomain('put access request').accessRequests.put(record.token, record)
191
+ this.requests.set(record.token, record)
192
+ return record
193
+ }
194
+
195
+ /**
196
+ * Mark every pending request past its deadline as expired. Returns the
197
+ * settled records so the caller can patch stale DM cards.
198
+ */
199
+ async expireRequests(now = Date.now()): Promise<AccessRequestRecord[]> {
200
+ const expired: AccessRequestRecord[] = []
201
+ for (const record of [...this.requests.values()]) {
202
+ if (record.status !== 'pending' || record.expiresAt > now) continue
203
+ const next: AccessRequestRecord = { ...record, status: 'expired' }
204
+ try {
205
+ await this.requireDomain('expire access request').accessRequests.put(next.token, next)
206
+ } catch (error) {
207
+ this.deps.warn('dsh-channels: persisting expired access request %s failed: %s', next.token, String(error))
208
+ }
209
+ this.requests.set(next.token, next)
210
+ expired.push(next)
211
+ }
212
+ return expired
213
+ }
214
+
215
+ private requireDomain(action: string): FeishuDomainHandle {
216
+ const domain = this.deps.domain()
217
+ if (domain === undefined) throw new Error(`dsh-channels: access domain not open (${action})`)
218
+ return domain
219
+ }
220
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public barrel for the HITL approval answerer (implementation lives in
3
+ * `src/interactions/approvals.ts`).
4
+ *
5
+ * @module dsh-channels/approvals
6
+ */
7
+
8
+ export * from './interactions/approvals.ts'
@@ -0,0 +1,114 @@
1
+ /**
2
+ * HITL approval decision cards (`approval/request`).
3
+ *
4
+ * @module dsh-channels/cards/approval
5
+ */
6
+
7
+ import { columnSet, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
8
+
9
+ // ---- HITL approval decision cards ----------------------------------------
10
+
11
+ /** Terminal states an approval decision card can settle into. */
12
+ export type ApprovalCardState = 'allowed' | 'denied' | 'expired' | 'cancelled'
13
+
14
+ /** The question one approval decision card presents. */
15
+ export interface ApprovalQuestion {
16
+ /** Opaque token binding the card to one pending approval (button value). */
17
+ token: string
18
+ /** Model-facing tool name asking for approval (e.g. `bash`). */
19
+ toolName: string
20
+ /** The asker's human-readable justification; clipped to a safe length. */
21
+ reason?: string
22
+ /** Timeout window shown on the card (milliseconds). */
23
+ timeoutMs: number
24
+ /** Session id trace line (omitted when absent). */
25
+ sessionId?: string
26
+ }
27
+
28
+ /** Approval card rendering options. */
29
+ export interface ApprovalCardOptions {
30
+ /** Add the owner-only notice (a non-owner clicked and was ignored). */
31
+ unauthorizedNote?: boolean
32
+ }
33
+
34
+ /** Cap on the rendered reason so the card can never approach the size budget. */
35
+ const APPROVAL_REASON_MAX_CHARS = 1_000
36
+
37
+ /** `⏱ 5 分钟内有效…` — human timeout label (whole minutes, min 1). */
38
+ function approvalTimeoutLabel(timeoutMs: number): string {
39
+ return `${Math.max(1, Math.round(timeoutMs / 60_000))} 分钟`
40
+ }
41
+
42
+ /** One approval decision button with its callback payload. */
43
+ function approvalButton(question: ApprovalQuestion, decision: 'allow' | 'deny'): object {
44
+ const allow = decision === 'allow'
45
+ return textButton({
46
+ label: allow ? '✅ 允许' : '❌ 拒绝',
47
+ type: allow ? 'primary' : 'danger',
48
+ width: 'fill',
49
+ margin: '0px',
50
+ value: { decision, token: question.token },
51
+ })
52
+ }
53
+
54
+ /** Two-column row that keeps both decisions side by side. */
55
+ function approvalActions(question: ApprovalQuestion): object {
56
+ return columnSet((['allow', 'deny'] as const).map(decision => approvalButton(question, decision)))
57
+ }
58
+
59
+ /** The question summary shared by the pending and settled variants. */
60
+ function approvalQuestionElements(question: ApprovalQuestion): object[] {
61
+ const elements: object[] = [
62
+ markdown(`工具 \`${question.toolName}\` 请求执行一个需要授权的操作。`),
63
+ ]
64
+ if (question.reason !== undefined && question.reason !== '') {
65
+ const clipped = question.reason.length > APPROVAL_REASON_MAX_CHARS
66
+ ? `${question.reason.slice(0, APPROVAL_REASON_MAX_CHARS)}…`
67
+ : question.reason
68
+ elements.push(markdown(`> ${clipped.replace(/\s+/gu, ' ')}`))
69
+ }
70
+ return elements
71
+ }
72
+
73
+ /**
74
+ * The pending approval decision card: question summary, timeout note, and the
75
+ * enabled allow/deny buttons. Sent as a NEW standalone message anchored to
76
+ * the session's latest streaming card (never embedded into the streamed
77
+ * card, whose full-replacement patches would clobber the buttons).
78
+ */
79
+ export function buildApprovalCard(question: ApprovalQuestion, options: ApprovalCardOptions = {}): object {
80
+ const elements: object[] = [
81
+ ...approvalQuestionElements(question),
82
+ divider(),
83
+ note(`⏱ ${approvalTimeoutLabel(question.timeoutMs)}内有效,超时自动拒绝 · 允许仅对本次动作生效`),
84
+ ]
85
+ if (options.unauthorizedNote === true) {
86
+ elements.push(note('ℹ️ 仅发起该会话的用户可操作此卡片'))
87
+ }
88
+ if (question.sessionId !== undefined) {
89
+ elements.push(note(`🔑 ${question.sessionId}`))
90
+ }
91
+ elements.push(approvalActions(question))
92
+ return standaloneCard('**⚠️ 需要你的审批**', elements)
93
+ }
94
+
95
+ /** Settled-card status lines, one per terminal state. */
96
+ const APPROVAL_SETTLED_LINES: Record<ApprovalCardState, string> = {
97
+ allowed: '✅ 已允许执行',
98
+ denied: '❌ 已拒绝',
99
+ expired: '⏱ 已超时未处理,自动拒绝',
100
+ cancelled: '↩️ 请求已取消',
101
+ }
102
+
103
+ /**
104
+ * The terminal replacement for a decision card after the question settles
105
+ * (decision, timeout, or withdrawal). Keeps the question summary for context
106
+ * and removes the decision controls entirely.
107
+ */
108
+ export function buildApprovalSettledCard(question: ApprovalQuestion, state: ApprovalCardState): object {
109
+ return standaloneCard(`**${APPROVAL_SETTLED_LINES[state]}**`, [
110
+ ...approvalQuestionElements(question),
111
+ divider(),
112
+ ])
113
+ }
114
+
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Card rendering barrel: one import specifier for every card this plugin
3
+ * builds, while the implementations stay grouped by surface.
4
+ *
5
+ * @module dsh-channels/cards
6
+ */
7
+
8
+ export * from './primitives.ts'
9
+ export * from './streaming.ts'
10
+ export * from './approval.ts'
11
+ export * from './question.ts'
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Card JSON 2.0 primitives shared by every card this plugin renders.
3
+ *
4
+ * One envelope + one element vocabulary keeps the byte budget, the width
5
+ * mode and the callback shape in a single place; card modules only describe
6
+ * their content.
7
+ *
8
+ * @module dsh-channels/cards/primitives
9
+ */
10
+
11
+ /** Feishu card JSON schema version this plugin targets. */
12
+ export const CARD_SCHEMA = '2.0'
13
+
14
+ /** Default card config: full-width cards in the chat pane. */
15
+ export const CARD_CONFIG: Record<string, unknown> = { width_mode: 'fill' }
16
+
17
+ /** Wrap elements in a card 2.0 envelope. */
18
+ export function card(elements: object[], config: Record<string, unknown> = CARD_CONFIG): object {
19
+ return { schema: CARD_SCHEMA, config, body: { elements } }
20
+ }
21
+
22
+ /** Markdown element with optional presentation fields (`text_size`, …). */
23
+ export function markdown(content: string, extra: Record<string, unknown> = {}): object {
24
+ return { tag: 'markdown', content, ...extra }
25
+ }
26
+
27
+ /** Small notation-size markdown, used for hints and footers. */
28
+ export function note(content: string): object {
29
+ return markdown(content, { text_size: 'notation' })
30
+ }
31
+
32
+ /** Horizontal divider. */
33
+ export function divider(): object {
34
+ return { tag: 'hr' }
35
+ }
36
+
37
+ export interface TextButtonOptions {
38
+ label: string
39
+ /** Callback payload carried back by `card.action.trigger`. */
40
+ value: unknown
41
+ type?: 'default' | 'primary' | 'danger'
42
+ disabled?: boolean
43
+ width?: 'fill' | 'default'
44
+ margin?: string
45
+ }
46
+
47
+ /** One callback button (`behaviors[0].value` is the plugin's action object). */
48
+ export function textButton(options: TextButtonOptions): object {
49
+ return {
50
+ tag: 'button',
51
+ type: options.type ?? 'default',
52
+ width: options.width ?? 'fill',
53
+ margin: options.margin ?? '4px 0 0 0',
54
+ ...(options.disabled === true ? { disabled: true } : {}),
55
+ text: { tag: 'plain_text', content: options.label },
56
+ behaviors: [{ type: 'callback', value: options.value }],
57
+ }
58
+ }
59
+
60
+ /** A row of equal-width columns, each holding one element. */
61
+ export function columnSet(
62
+ columns: object[],
63
+ options: { flexMode?: string; spacing?: string; margin?: string } = {},
64
+ ): object {
65
+ return {
66
+ tag: 'column_set',
67
+ flex_mode: options.flexMode ?? 'bisect',
68
+ horizontal_spacing: options.spacing ?? 'medium',
69
+ margin: options.margin ?? '4px 0 0 0',
70
+ columns: columns.map(element => ({ tag: 'column', width: 'weighted', weight: 1, elements: [element] })),
71
+ }
72
+ }
73
+
74
+ /** Titled standalone card: bold title element followed by the body elements. */
75
+ export function standaloneCard(title: string, elements: object[], config?: Record<string, unknown>): object {
76
+ return card([markdown(title), ...elements], config)
77
+ }
78
+
79
+ /** Titled card whose body is one markdown element per supplied line. */
80
+ export function standaloneInfoCard(title: string, lines: readonly string[]): object {
81
+ return standaloneCard(title, lines.map(line => markdown(line)))
82
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Structured user-question cards (`user-questions/request`).
3
+ *
4
+ * @module dsh-channels/cards/question
5
+ */
6
+
7
+ import { card, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
8
+
9
+ // ---- structured user-question cards --------------------------------------
10
+
11
+ /** Terminal states a user-question card can settle into. */
12
+ export type QuestionCardState = 'answered' | 'expired' | 'cancelled'
13
+
14
+ /** One question as rendered (options are always present after normalization). */
15
+ export interface QuestionCardItem {
16
+ id: string
17
+ question: string
18
+ detail?: string
19
+ header?: string
20
+ options: Array<{ label: string; description?: string }>
21
+ multiSelect: boolean
22
+ /** `plan-review` intent's approve label (presentation only). */
23
+ intentApproved?: string
24
+ }
25
+
26
+ /** One pending question card's render input. */
27
+ export interface QuestionCardInput {
28
+ /** Opaque token binding the card to one pending question set. */
29
+ token: string
30
+ questions: QuestionCardItem[]
31
+ timeoutMs: number
32
+ sessionId?: string
33
+ /** Multi-select toggles accumulated so far, by question id. */
34
+ selected?: Record<string, string[]>
35
+ /** Question ids already answered (single-select picks). */
36
+ answered?: readonly string[]
37
+ /** Add the owner-only notice (a non-owner clicked and was ignored). */
38
+ unauthorizedNote?: boolean
39
+ }
40
+
41
+ /** Cap on rendered strings so one question cannot approach the card budget. */
42
+ const QUESTION_TEXT_MAX_CHARS = 500
43
+ /** Cap on rendered options per question (excess is reported, not dropped silently). */
44
+ export const MAX_QUESTION_OPTIONS = 8
45
+
46
+ function clipQuestionText(text: string, max = QUESTION_TEXT_MAX_CHARS): string {
47
+ return text.length <= max ? text : `${text.slice(0, max)}…`
48
+ }
49
+
50
+ function questionAnswered(input: QuestionCardInput, id: string): boolean {
51
+ return (input.answered ?? []).includes(id)
52
+ }
53
+
54
+ function questionOptionButton(
55
+ input: QuestionCardInput,
56
+ question: QuestionCardItem,
57
+ option: { label: string; description?: string },
58
+ ): object {
59
+ const isMulti = question.multiSelect
60
+ const selected = (input.selected?.[question.id] ?? []).includes(option.label)
61
+ const approved = question.intentApproved === option.label
62
+ const answered = questionAnswered(input, question.id)
63
+ const type = isMulti
64
+ ? (selected ? 'primary' : 'default')
65
+ : (approved ? 'primary' : 'default')
66
+ const label = isMulti && selected ? `✅ ${option.label}` : option.label
67
+ return textButton({
68
+ label: clipQuestionText(label, 80),
69
+ type: answered ? 'default' : type,
70
+ width: 'fill',
71
+ disabled: answered,
72
+ value: {
73
+ decision: isMulti ? 'question-toggle' : 'question-answer',
74
+ token: input.token,
75
+ id: question.id,
76
+ value: option.label,
77
+ },
78
+ })
79
+ }
80
+
81
+ function questionElements(input: QuestionCardInput): object[] {
82
+ const elements: object[] = [markdown('**❓ 需要你的回答**')]
83
+ input.questions.forEach((question, index) => {
84
+ const heading = question.header === undefined || question.header === ''
85
+ ? `**问题 ${index + 1}**`
86
+ : `**问题 ${index + 1} · ${clipQuestionText(question.header, 80)}**`
87
+ elements.push(markdown(`${heading}\n${clipQuestionText(question.question)}`))
88
+ if (question.detail !== undefined && question.detail !== '') {
89
+ elements.push(markdown(`> ${clipQuestionText(question.detail, 1_200).replace(/\s+/gu, ' ')}`))
90
+ }
91
+ const options = question.options.slice(0, MAX_QUESTION_OPTIONS)
92
+ for (const option of options) {
93
+ elements.push(questionOptionButton(input, question, option))
94
+ }
95
+ if (question.options.length === 0) {
96
+ elements.push(note('本题没有预设选项,请直接回复文字作答。'))
97
+ } else if (question.options.length > options.length) {
98
+ elements.push(note(`另有 ${question.options.length - options.length} 个选项未展示,请在 Web 端回答。`))
99
+ }
100
+ if (question.multiSelect) {
101
+ const selected = input.selected?.[question.id] ?? []
102
+ if (selected.length > 0 && !questionAnswered(input, question.id)) {
103
+ elements.push(textButton({
104
+ label: '提交本题选择',
105
+ type: 'primary',
106
+ value: { decision: 'question-submit', token: input.token, id: question.id },
107
+ }))
108
+ }
109
+ elements.push(note(`本题为多选:点选后请点“提交本题选择”(已选 ${selected.length} 项)。`))
110
+ }
111
+ })
112
+ elements.push(divider())
113
+ elements.push(note(`⏱ ${Math.max(1, Math.round(input.timeoutMs / 60_000))} 分钟内有效,超时后本轮提问失败 · 仅发起该会话的用户可操作 · 也可直接回复文字作答`))
114
+ if (input.sessionId !== undefined) elements.push(note(`🔑 ${input.sessionId}`))
115
+ if (input.unauthorizedNote === true) elements.push(note('ℹ️ 仅发起该会话的用户可操作此卡片'))
116
+ return elements
117
+ }
118
+
119
+ /** The pending structured-question card (one card carries every question). */
120
+ export function buildQuestionCard(input: QuestionCardInput): object {
121
+ return card(questionElements(input))
122
+ }
123
+
124
+ const QUESTION_SETTLED_LINES: Record<QuestionCardState, string> = {
125
+ answered: '✅ 已回答',
126
+ expired: '⏱ 已超时未处理,本轮提问已失败',
127
+ cancelled: '↩️ 提问已取消',
128
+ }
129
+
130
+ /** The terminal replacement after the question set settles; controls removed. */
131
+ export function buildQuestionSettledCard(input: QuestionCardInput, state: QuestionCardState): object {
132
+ const elements: object[] = [
133
+ ...input.questions.slice(0, 4).map((question, index) =>
134
+ markdown(`**问题 ${index + 1}**:${clipQuestionText(question.question, 200)}`)),
135
+ divider(),
136
+ ]
137
+ if (state === 'expired') {
138
+ elements.push(note('提问窗口已关闭。可在下一条消息中要求模型重新提问。'))
139
+ }
140
+ return standaloneCard(`**${QUESTION_SETTLED_LINES[state]}**`, elements)
141
+ }