@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- 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
|
+
}
|
package/src/approvals.ts
ADDED
|
@@ -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
|
+
}
|