@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2

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 (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
@@ -8,8 +8,21 @@ import { columnSet, divider, markdown, note, standaloneCard, textButton } from '
8
8
 
9
9
  // ---- HITL approval decision cards ----------------------------------------
10
10
 
11
- /** Terminal states an approval decision card can settle into. */
12
- export type ApprovalCardState = 'allowed' | 'denied' | 'expired' | 'cancelled'
11
+ /**
12
+ * Terminal states an approval decision card can settle into. `allowed-text`
13
+ * and `denied-text` are decisions typed as a topic reply; `remembered` is the
14
+ * "stop asking in this topic" decision; `superseded` is a non-keyword reply
15
+ * that rejected the request and was queued as the next instruction.
16
+ */
17
+ export type ApprovalCardState =
18
+ | 'allowed'
19
+ | 'allowed-text'
20
+ | 'remembered'
21
+ | 'denied'
22
+ | 'denied-text'
23
+ | 'superseded'
24
+ | 'expired'
25
+ | 'cancelled'
13
26
 
14
27
  /** The question one approval decision card presents. */
15
28
  export interface ApprovalQuestion {
@@ -19,6 +32,10 @@ export interface ApprovalQuestion {
19
32
  toolName: string
20
33
  /** The asker's human-readable justification; clipped to a safe length. */
21
34
  reason?: string
35
+ /** What the call will run: the bash command or the raw arguments JSON. */
36
+ command?: string
37
+ /** The sandbox mode the call asks to be raised to, when it asks. */
38
+ escalateTo?: string
22
39
  /** Timeout window shown on the card (milliseconds). */
23
40
  timeoutMs: number
24
41
  /** Session id trace line (omitted when absent). */
@@ -31,56 +48,105 @@ export interface ApprovalCardOptions {
31
48
  unauthorizedNote?: boolean
32
49
  }
33
50
 
51
+ /** Cap on the rendered command so the card stays readable on a phone. */
52
+ const APPROVAL_COMMAND_MAX_CHARS = 600
53
+
34
54
  /** Cap on the rendered reason so the card can never approach the size budget. */
35
55
  const APPROVAL_REASON_MAX_CHARS = 1_000
36
56
 
57
+ /** The sandbox mode whose standing grant removes all confinement. */
58
+ const UNCONFINED_MODE = 'danger-full-access'
59
+
37
60
  /** `⏱ 5 分钟内有效…` — human timeout label (whole minutes, min 1). */
38
61
  function approvalTimeoutLabel(timeoutMs: number): string {
39
62
  return `${Math.max(1, Math.round(timeoutMs / 60_000))} 分钟`
40
63
  }
41
64
 
65
+ /** Clip to `max` chars and report how many were dropped. */
66
+ function clip(value: string, max: number): { shown: string; hidden: number } {
67
+ return value.length <= max
68
+ ? { shown: value, hidden: 0 }
69
+ : { shown: value.slice(0, max), hidden: value.length - max }
70
+ }
71
+
72
+ /**
73
+ * What a "stop asking in this topic" grant covers, as the card states it:
74
+ * `bash 提升到 workspace-write` or the bare tool name.
75
+ * @param question - the question whose grant scope is described.
76
+ * @returns The scope phrase.
77
+ */
78
+ export function approvalGrantScope(question: Pick<ApprovalQuestion, 'toolName' | 'escalateTo'>): string {
79
+ return question.escalateTo === undefined
80
+ ? `\`${question.toolName}\``
81
+ : `\`${question.toolName}\` 提升到 \`${question.escalateTo}\``
82
+ }
83
+
84
+ type ApprovalDecision = 'allow' | 'always' | 'deny'
85
+
42
86
  /** One approval decision button with its callback payload. */
43
- function approvalButton(question: ApprovalQuestion, decision: 'allow' | 'deny'): object {
44
- const allow = decision === 'allow'
87
+ function approvalButton(question: ApprovalQuestion, decision: ApprovalDecision): object {
88
+ const label = decision === 'allow' ? '✅ 允许' : decision === 'always' ? '♾️ 本话题不再询问' : '❌ 拒绝'
89
+ const type = decision === 'allow'
90
+ ? 'primary'
91
+ : decision === 'always'
92
+ ? (question.escalateTo === UNCONFINED_MODE ? 'danger' : 'default')
93
+ : 'danger'
45
94
  return textButton({
46
- label: allow ? '✅ 允许' : '❌ 拒绝',
47
- type: allow ? 'primary' : 'danger',
95
+ label,
96
+ type,
48
97
  width: 'fill',
49
- margin: '0px',
98
+ margin: decision === 'always' ? '8px 0 0 0' : '0px',
50
99
  value: { decision, token: question.token },
51
100
  })
52
101
  }
53
102
 
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)))
103
+ /**
104
+ * The decision controls: allow and deny side by side, then the full-width
105
+ * "stop asking in this topic" button with the grant's scope under it.
106
+ */
107
+ function approvalActions(question: ApprovalQuestion): object[] {
108
+ const scope = question.escalateTo === UNCONFINED_MODE
109
+ ? `之后本话题内 ${approvalGrantScope(question)} 都自动允许,等于完全不受沙箱限制`
110
+ : `之后本话题内 ${approvalGrantScope(question)} 都自动允许`
111
+ return [
112
+ columnSet((['allow', 'deny'] as const).map(decision => approvalButton(question, decision))),
113
+ approvalButton(question, 'always'),
114
+ note(`♾️ ${scope};/renew 或重启后恢复询问`),
115
+ ]
57
116
  }
58
117
 
59
118
  /** The question summary shared by the pending and settled variants. */
60
119
  function approvalQuestionElements(question: ApprovalQuestion): object[] {
61
120
  const elements: object[] = [
62
- markdown(`工具 \`${question.toolName}\` 请求执行一个需要授权的操作。`),
121
+ markdown(question.escalateTo === undefined
122
+ ? `工具 \`${question.toolName}\` 请求执行一个需要授权的操作。`
123
+ : `工具 \`${question.toolName}\` 请求把本次调用提升到 \`${question.escalateTo}\` 权限。`),
63
124
  ]
125
+ if (question.command !== undefined && question.command !== '') {
126
+ const command = clip(question.command, APPROVAL_COMMAND_MAX_CHARS)
127
+ elements.push(markdown(`**将执行**\n\`\`\`\n${command.shown.replace(/```/gu, '`​``')}\n\`\`\``))
128
+ if (command.hidden > 0) elements.push(note(`已截断 ${command.hidden} 个字符`))
129
+ }
64
130
  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, ' ')}`))
131
+ const reason = clip(question.reason, APPROVAL_REASON_MAX_CHARS)
132
+ elements.push(markdown(`> ${reason.shown.replace(/\s+/gu, ' ')}${reason.hidden > 0 ? '…' : ''}`))
69
133
  }
70
134
  return elements
71
135
  }
72
136
 
73
137
  /**
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).
138
+ * The pending approval decision card: question summary (with the command
139
+ * when known), how to answer, and the enabled decision buttons. Sent as a NEW
140
+ * standalone message anchored to the session's latest streaming card (never
141
+ * embedded into the streamed card, whose full-replacement patches would
142
+ * clobber the buttons).
78
143
  */
79
144
  export function buildApprovalCard(question: ApprovalQuestion, options: ApprovalCardOptions = {}): object {
80
145
  const elements: object[] = [
81
146
  ...approvalQuestionElements(question),
82
147
  divider(),
83
- note(`⏱ ${approvalTimeoutLabel(question.timeoutMs)}内有效,超时自动拒绝 · 允许仅对本次动作生效`),
148
+ note(`⏱ ${approvalTimeoutLabel(question.timeoutMs)}内有效,超时自动拒绝 · 「允许」仅对本次生效`),
149
+ note('💬 也可直接回复「允许」「拒绝」「全部允许」;回复其他内容视为拒绝,并作为下一条指令'),
84
150
  ]
85
151
  if (options.unauthorizedNote === true) {
86
152
  elements.push(note('ℹ️ 仅发起该会话的用户可操作此卡片'))
@@ -88,14 +154,18 @@ export function buildApprovalCard(question: ApprovalQuestion, options: ApprovalC
88
154
  if (question.sessionId !== undefined) {
89
155
  elements.push(note(`🔑 ${question.sessionId}`))
90
156
  }
91
- elements.push(approvalActions(question))
157
+ elements.push(...approvalActions(question))
92
158
  return standaloneCard('**⚠️ 需要你的审批**', elements)
93
159
  }
94
160
 
95
161
  /** Settled-card status lines, one per terminal state. */
96
162
  const APPROVAL_SETTLED_LINES: Record<ApprovalCardState, string> = {
97
163
  allowed: '✅ 已允许执行',
164
+ 'allowed-text': '✅ 已允许执行(文字回复)',
165
+ remembered: '♾️ 已允许,本话题不再询问',
98
166
  denied: '❌ 已拒绝',
167
+ 'denied-text': '❌ 已拒绝(文字回复)',
168
+ superseded: '❌ 已拒绝,你的新消息将作为下一条指令',
99
169
  expired: '⏱ 已超时未处理,自动拒绝',
100
170
  cancelled: '↩️ 请求已取消',
101
171
  }
@@ -106,9 +176,9 @@ const APPROVAL_SETTLED_LINES: Record<ApprovalCardState, string> = {
106
176
  * and removes the decision controls entirely.
107
177
  */
108
178
  export function buildApprovalSettledCard(question: ApprovalQuestion, state: ApprovalCardState): object {
109
- return standaloneCard(`**${APPROVAL_SETTLED_LINES[state]}**`, [
110
- ...approvalQuestionElements(question),
111
- divider(),
112
- ])
179
+ const elements = [...approvalQuestionElements(question), divider()]
180
+ if (state === 'remembered') {
181
+ elements.push(note(`本话题内 ${approvalGrantScope(question)} 将自动允许;/renew 或重启后恢复询问`))
182
+ }
183
+ return standaloneCard(`**${APPROVAL_SETTLED_LINES[state]}**`, elements)
113
184
  }
114
-
@@ -10,3 +10,4 @@ export * from './streaming.ts'
10
10
  export * from './onboarding.ts'
11
11
  export * from './approval.ts'
12
12
  export * from './question.ts'
13
+ export * from './picker.ts'
@@ -15,6 +15,7 @@ export function buildOnboardingCard(): object {
15
15
  markdown('**👋 欢迎使用飞书 DSH**'),
16
16
  markdown('直接发消息即可开始一个会话,我会在话题里流式回复;话题内继续发消息无需再 @ 我。'),
17
17
  markdown('**工作区**:私聊一人一个;群里每位成员各自一个,互不影响。'),
18
+ markdown('**发文件**:只发文件我不会回复;请引用那条文件消息再提问,多个文件可合并转发后引用。'),
18
19
  markdown('**常用命令**:`/help` 命令列表 · `/status` 会话状态 · `/workspace` 工作目录\n'
19
20
  + '`/effort` 推理强度 · `/model` 模型 · `/permissions` 权限预设\n'
20
21
  + '`/new` 新建会话 · `/stop` 中断当前回合 · `/history` 历史 · `/export` 导出日志'),
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Command picker cards: `/model`, `/effort` and `/permissions` without
3
+ * arguments answer with clickable choices instead of ids to retype.
4
+ *
5
+ * A click carries the command and its argument back to the service, which
6
+ * runs it exactly like the typed `/<command> <arg>` line, so admission,
7
+ * ownership and validation stay in one place; the result replaces the picker
8
+ * card in place.
9
+ *
10
+ * @module dsh-channels/cards/picker
11
+ */
12
+
13
+ import { divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
14
+
15
+ /** `decision` carried by every picker button. */
16
+ export const PICK_ACTION = 'command-pick'
17
+
18
+ /** Commands that answer their bare form with a picker card. */
19
+ export const PICKER_COMMANDS = ['model', 'effort', 'permissions'] as const
20
+ export type PickerCommand = typeof PICKER_COMMANDS[number]
21
+
22
+ /** Cap on rendered choices so one picker cannot approach the card budget. */
23
+ export const MAX_PICKER_OPTIONS = 12
24
+
25
+ /** Callback value of one picker button. */
26
+ export interface PickAction {
27
+ decision: typeof PICK_ACTION
28
+ command: PickerCommand
29
+ /** Argument appended to the command line (`/model <arg>`). */
30
+ arg: string
31
+ /** Session the picker was rendered for; a rebound topic makes it stale. */
32
+ sessionId: string
33
+ }
34
+
35
+ /** One selectable choice. */
36
+ export interface PickerOption {
37
+ label: string
38
+ /** Command argument; never contains whitespace. */
39
+ arg: string
40
+ }
41
+
42
+ /** One picker card's render input. */
43
+ export interface PickerCardInput {
44
+ title: string
45
+ command: PickerCommand
46
+ /** Markdown shown above the choices (the command's own text answer). */
47
+ text: string
48
+ sessionId: string
49
+ options: readonly PickerOption[]
50
+ /** `arg` of the active choice, rendered highlighted. */
51
+ current?: string
52
+ /** Add a 恢复默认 button that runs `/<command> reset`. */
53
+ reset?: boolean
54
+ }
55
+
56
+ function pickButton(input: PickerCardInput, label: string, arg: string): object {
57
+ const active = arg === input.current
58
+ const value: PickAction = { decision: PICK_ACTION, command: input.command, arg, sessionId: input.sessionId }
59
+ return textButton({
60
+ label: active ? `✅ ${label}` : label,
61
+ type: active ? 'primary' : 'default',
62
+ value,
63
+ })
64
+ }
65
+
66
+ /** Whether a value can be appended to a command line as one argument. */
67
+ export function isPickerArg(value: string): boolean {
68
+ return value !== '' && !/\s/u.test(value)
69
+ }
70
+
71
+ /**
72
+ * The picker card: one full-width button per choice, in a single column.
73
+ * Options whose argument cannot form one command-line token
74
+ * are skipped; when more than `MAX_PICKER_OPTIONS` remain the rest are
75
+ * reported as typed-only.
76
+ */
77
+ export function buildPickerCard(input: PickerCardInput): object {
78
+ const usable = input.options.filter(option => isPickerArg(option.arg))
79
+ const shown = usable.slice(0, MAX_PICKER_OPTIONS)
80
+ const elements: object[] = [
81
+ markdown(input.text),
82
+ ...shown.map(option => pickButton(input, option.label, option.arg)),
83
+ ]
84
+ if (usable.length > shown.length) {
85
+ elements.push(note(`另有 ${usable.length - shown.length} 项未展示,可直接输入 /${input.command} <id>。`))
86
+ }
87
+ if (input.reset === true) {
88
+ elements.push(textButton({
89
+ label: '恢复默认',
90
+ value: { decision: PICK_ACTION, command: input.command, arg: 'reset', sessionId: input.sessionId } satisfies PickAction,
91
+ }))
92
+ }
93
+ elements.push(divider())
94
+ elements.push(note(`点击即切换 · 仅发起该会话的用户可操作 · 🔑 ${input.sessionId}`))
95
+ return standaloneCard(`**${input.title}**`, elements)
96
+ }
97
+
98
+ /** Parse a card action value into a picker action; undefined for anything else. */
99
+ export function parsePickAction(value: unknown): PickAction | undefined {
100
+ if (typeof value !== 'object' || value === null) return undefined
101
+ const record = value as Record<string, unknown>
102
+ if (record.decision !== PICK_ACTION) return undefined
103
+ const { command, arg, sessionId } = record
104
+ if (typeof command !== 'string' || !(PICKER_COMMANDS as readonly string[]).includes(command)) return undefined
105
+ if (typeof arg !== 'string' || !isPickerArg(arg)) return undefined
106
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
107
+ return { decision: PICK_ACTION, command: command as PickerCommand, arg, sessionId }
108
+ }
@@ -26,6 +26,10 @@
26
26
  * Continuation cards (`options.segment`) carry a segment header and the body;
27
27
  * they have no panel and no footer, so a long answer is not repeated chrome.
28
28
  *
29
+ * The stop button follows the activity lines: the streamer only passes it for
30
+ * the card that is currently streaming and drops it once the turn settles, so
31
+ * a settled card never offers a control that can no longer act.
32
+ *
29
33
  * Budgets, two independent ones:
30
34
  * - Each card is measured as a whole: segmentation targets `CARD_TARGET_BYTES`
31
35
  * (main cards probe the panel at its maximum window, so reasoning that keeps
@@ -39,17 +43,27 @@
39
43
  * @module dsh-channels/cards/streaming
40
44
  */
41
45
 
42
- import { card, divider, foldPanel, markdown, note } from './primitives.ts'
46
+ import { card, divider, foldPanel, markdown, note, textButton } from './primitives.ts'
43
47
 
44
- export type PanelPhase = 'thinking' | 'replying' | 'done' | 'error'
48
+ export type PanelPhase = 'thinking' | 'replying' | 'done' | 'stopped' | 'error'
45
49
 
46
50
  export const PANEL_TITLES: Record<PanelPhase, string> = {
47
51
  thinking: '推理中',
48
52
  replying: '回复中',
49
53
  done: '回复结束',
54
+ stopped: '已停止',
50
55
  error: '回复失败',
51
56
  }
52
57
 
58
+ /** `decision` carried by the streaming card's stop button. */
59
+ export const STOP_ACTION = 'turn-stop'
60
+
61
+ /**
62
+ * Stop control state on the streaming card: `ready` offers the button,
63
+ * `stopping` shows it disabled after the owner clicked it.
64
+ */
65
+ export type StopControl = 'ready' | 'stopping'
66
+
53
67
  /** Built-in icon token for the fold-panel chevron (from the official component docs). */
54
68
  export const PANEL_CHEVRON_TOKEN = 'down-small-ccm_outlined'
55
69
 
@@ -124,6 +138,14 @@ export interface CardState {
124
138
  * Empty/undefined → omitted.
125
139
  */
126
140
  tool?: string
141
+ /**
142
+ * Stop control for the actively streaming card. Same rule as `status`: the
143
+ * streamer passes it only for the current card of an unsettled turn.
144
+ * Undefined → no button.
145
+ */
146
+ stop?: StopControl
147
+ /** Add the owner-only notice under the stop button (a non-owner clicked it). */
148
+ stopDenied?: boolean
127
149
  stats: CardStats
128
150
  }
129
151
 
@@ -311,7 +333,7 @@ function reasoningPlaceholder(phase: PanelPhase): string {
311
333
  }
312
334
 
313
335
  function bodyPlaceholder(phase: PanelPhase): string {
314
- return phase === 'done' || phase === 'error' ? '*(本次无正文输出)*' : '*正在生成…*'
336
+ return isSettled(phase) ? '*(本次无正文输出)*' : '*正在生成…*'
315
337
  }
316
338
 
317
339
  /** The panel's markdown: stripped of tables, with a placeholder when empty. */
@@ -341,7 +363,7 @@ function interruptionHeader(segment: number): string {
341
363
  }
342
364
 
343
365
  function isSettled(phase: PanelPhase): boolean {
344
- return phase === 'done' || phase === 'error'
366
+ return phase === 'done' || phase === 'stopped' || phase === 'error'
345
367
  }
346
368
 
347
369
  /**
@@ -360,6 +382,23 @@ function activityNote(text: string | undefined): object | undefined {
360
382
  return note(clipped)
361
383
  }
362
384
 
385
+ /** Main-card line of a turn that ended through a stop request. */
386
+ export const STOPPED_NOTICE = '⏹ 已按请求停止本轮回复'
387
+
388
+ /** The stop button plus its optional owner-only notice. */
389
+ function stopElements(stop: StopControl, denied: boolean): object[] {
390
+ const elements: object[] = [textButton({
391
+ label: stop === 'stopping' ? '正在停止…' : '⏹ 停止',
392
+ type: 'default',
393
+ width: 'default',
394
+ margin: '8px 0 0 0',
395
+ disabled: stop === 'stopping',
396
+ value: { decision: STOP_ACTION },
397
+ })]
398
+ if (denied) elements.push(note('ℹ️ 仅发起该会话的用户可停止'))
399
+ return elements
400
+ }
401
+
363
402
  // ---- rendering ------------------------------------------------------------
364
403
 
365
404
  /** Render one card. `reasoning`/`body` are the already-budgeted texts. */
@@ -392,6 +431,10 @@ function renderCard(
392
431
 
393
432
  elements.push(markdown(body.trim() === '' ? bodyPlaceholder(state.phase) : body))
394
433
 
434
+ if (state.stop !== undefined) elements.push(...stopElements(state.stop, state.stopDenied === true))
435
+
436
+ if (main && state.phase === 'stopped') elements.push(note(STOPPED_NOTICE))
437
+
395
438
  if (main && isSettled(state.phase)) {
396
439
  // The divider separates the answer from the turn's metadata; the two
397
440
  // notation lines below it are the only place the settled figures appear.
@@ -474,8 +517,10 @@ const PANEL_PROBE_MARKDOWN = 'r'.repeat(REASONING_MAX_BYTES - 2)
474
517
  *
475
518
  * The probe renders the worst card this segment can ever become: the main card
476
519
  * always budgets the panel's full window (reasoning may keep growing after the
477
- * split) and its settled footer, while continuation cards have neither. The
478
- * `error` phase is probed because it has the longest panel title.
520
+ * split) and its settled footer, while continuation cards have neither. Every
521
+ * card also budgets the stop button with its notice, since the newest card
522
+ * carries it while streaming. The `error` phase is probed because it has the
523
+ * longest panel title.
479
524
  */
480
525
  export function segmentCardContent(state: CardState, options: CardOptions = {}): BodySplit {
481
526
  const main = options.segment === undefined
@@ -483,6 +528,8 @@ export function segmentCardContent(state: CardState, options: CardOptions = {}):
483
528
  ...state,
484
529
  phase: 'error',
485
530
  reasoning: main ? PANEL_PROBE_MARKDOWN : '',
531
+ stop: 'stopping',
532
+ stopDenied: true,
486
533
  }
487
534
  const limit = fitPrefix(
488
535
  state.body,
@@ -568,7 +615,9 @@ export function splitBodyForCard(body: string, maxChars: number, maxTables: numb
568
615
 
569
616
  /**
570
617
  * The initial (empty) streaming card for one session: thinking phase, empty
571
- * body and reasoning, zeroed stats. The service sends it as the topic-creating
618
+ * body and reasoning, zeroed stats. It has no stop button: the turn may still
619
+ * be preparing or queued behind another one, and the streamer adds the button
620
+ * once the turn is the active one. The service sends it as the topic-creating
572
621
  * first reply and then streams into the same message.
573
622
  */
574
623
  export function initialStreamingCard(sessionId: string): object {
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Wiki (知识库) authorization cards: the topic owner's consent before the
3
+ * model opens, creates, or edits a wiki document.
4
+ *
5
+ * @module dsh-channels/cards/wiki
6
+ */
7
+
8
+ import { columnSet, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
9
+
10
+ /** What one wiki consent covers. */
11
+ export type WikiConsentAction = 'read' | 'create' | 'edit'
12
+
13
+ /** Terminal states of a wiki consent card. */
14
+ export type WikiConsentCardState = 'allowed' | 'denied' | 'expired' | 'cancelled'
15
+
16
+ /** The question one wiki consent card presents. */
17
+ export interface WikiConsentQuestion {
18
+ /** Opaque token binding the card to one pending consent (button value). */
19
+ readonly token: string
20
+ readonly action: WikiConsentAction
21
+ /** Document title (create: the new title). */
22
+ readonly title: string
23
+ /** Document link (read/edit) or parent link (create), when known. */
24
+ readonly url?: string
25
+ /** Where the document lives or will live, e.g. `空间名 / 父节点`. */
26
+ readonly location: string
27
+ /** Extra lines: operations of an edit, the content preview of a create. */
28
+ readonly details: readonly string[]
29
+ /** Timeout window shown on the card (milliseconds). */
30
+ readonly timeoutMs: number
31
+ }
32
+
33
+ /** Card rendering options. */
34
+ export interface WikiConsentCardOptions {
35
+ /** Add the owner-only notice (someone else clicked and was ignored). */
36
+ readonly unauthorizedNote?: boolean
37
+ }
38
+
39
+ /** Cap on one rendered detail block so the card stays readable on a phone. */
40
+ const DETAIL_MAX_CHARS = 800
41
+
42
+ const ACTION_LABELS: Readonly<Record<WikiConsentAction, string>> = {
43
+ read: '读取文档',
44
+ create: '新建文档',
45
+ edit: '修改文档',
46
+ }
47
+
48
+ const SETTLED_LINES: Readonly<Record<WikiConsentCardState, string>> = {
49
+ allowed: '✅ 已允许',
50
+ denied: '❌ 已拒绝',
51
+ expired: '⏱ 已超时未处理,自动拒绝',
52
+ cancelled: '↩️ 请求已取消',
53
+ }
54
+
55
+ /** Clip one value to `max` characters, marking the cut. */
56
+ function clip(value: string, max: number): string {
57
+ return value.length <= max ? value : `${value.slice(0, max)}…(已截断 ${value.length - max} 个字符)`
58
+ }
59
+
60
+ /** One line of Markdown text with link/markup characters neutralized. */
61
+ function plain(value: string): string {
62
+ return value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/([[\]()*_`~<>])/gu, '\\$1').trim()
63
+ }
64
+
65
+ /** The question summary shared by the pending and settled variants. */
66
+ function questionElements(question: WikiConsentQuestion): object[] {
67
+ const title = question.title === '' ? '(无标题)' : plain(question.title)
68
+ const titleLine = question.url === undefined || question.action === 'create'
69
+ ? title
70
+ : `[${title}](${question.url})`
71
+ const elements: object[] = [
72
+ markdown([
73
+ `机器人请求以应用身份**${ACTION_LABELS[question.action]}**。`,
74
+ `**文档**:${titleLine}`,
75
+ `**位置**:${plain(question.location)}`,
76
+ ].join('\n')),
77
+ ]
78
+ for (const detail of question.details) {
79
+ elements.push(markdown(clip(detail, DETAIL_MAX_CHARS)))
80
+ }
81
+ return elements
82
+ }
83
+
84
+ function consentButton(question: WikiConsentQuestion, decision: 'allow' | 'deny'): object {
85
+ return textButton({
86
+ label: decision === 'allow' ? '✅ 允许' : '❌ 拒绝',
87
+ type: decision === 'allow' ? 'primary' : 'danger',
88
+ width: 'fill',
89
+ value: { decision, token: question.token },
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The pending consent card, sent as a reply in the session's topic.
95
+ *
96
+ * @param question The consent asked for.
97
+ * @param options Rendering options.
98
+ * @returns Card JSON 2.0.
99
+ */
100
+ export function buildWikiConsentCard(question: WikiConsentQuestion, options: WikiConsentCardOptions = {}): object {
101
+ const minutes = Math.max(1, Math.round(question.timeoutMs / 60_000))
102
+ const scope = question.action === 'read'
103
+ ? '允许后,本话题内再次读取这篇文档不再询问'
104
+ : '仅对这一次操作生效'
105
+ const elements: object[] = [
106
+ ...questionElements(question),
107
+ divider(),
108
+ note(`⏱ ${minutes} 分钟内有效,超时自动拒绝 · ${scope}`),
109
+ ]
110
+ if (options.unauthorizedNote === true) elements.push(note('ℹ️ 仅发起该话题的用户可操作此卡片'))
111
+ elements.push(columnSet([consentButton(question, 'allow'), consentButton(question, 'deny')]))
112
+ return standaloneCard('**📚 知识库授权**', elements)
113
+ }
114
+
115
+ /**
116
+ * The terminal replacement after the consent settles; no controls.
117
+ *
118
+ * @param question The consent that was asked for.
119
+ * @param state How it settled.
120
+ * @returns Card JSON 2.0.
121
+ */
122
+ export function buildWikiConsentSettledCard(question: WikiConsentQuestion, state: WikiConsentCardState): object {
123
+ return standaloneCard(`**${SETTLED_LINES[state]}**`, [...questionElements(question), divider(), note(`📚 知识库${ACTION_LABELS[question.action]}`)])
124
+ }
package/src/client/api.ts CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  ACCESS_ROUTE,
12
12
  ACCESS_USERS_ROUTE,
13
13
  CATALOG_ROUTE,
14
- CLEANUP_ROUTE,
15
14
  CONFIG_ROUTE,
16
15
  CREDENTIALS_ROUTE,
17
16
  PANEL_ROUTE,
@@ -24,7 +23,6 @@ import type {
24
23
  AccessWire,
25
24
  CatalogWire,
26
25
  ChannelStatusWire,
27
- CleanupResponse,
28
26
  ClearConfigResponse,
29
27
  ClearCredentialResponse,
30
28
  CredentialField,
@@ -143,24 +141,6 @@ export async function reconnect(): Promise<ReconnectResponse> {
143
141
  return JSON.parse(text) as ReconnectResponse
144
142
  }
145
143
 
146
- /**
147
- * Run one workspace cleanup. No request body: the Host derives the candidate
148
- * set from the effective retention config. Irreversible, so the caller asks
149
- * for confirmation first; partial failures come back inside `result.failures`.
150
- */
151
- export async function runCleanup(): Promise<CleanupResponse> {
152
- const response = await request(CLEANUP_ROUTE, { method: 'POST' })
153
- const text = await response.text()
154
- if (!response.ok) {
155
- try {
156
- return JSON.parse(text) as CleanupResponse
157
- } catch {
158
- return { ok: false, error: errorFromBody(response.status, text) }
159
- }
160
- }
161
- return JSON.parse(text) as CleanupResponse
162
- }
163
-
164
144
  /**
165
145
  * Ask the Host to open its native directory chooser (the panel's「选择目录…」
166
146
  * button). The request stays open while the OS dialog does; a cancel comes back
@@ -259,18 +239,18 @@ export async function removeAccessGroup(chatId: string): Promise<AccessMutationR
259
239
  return mutateAccess(ACCESS_GROUPS_ROUTE, 'DELETE', { chatId })
260
240
  }
261
241
 
262
- /** Authorize one user open_id (global across authorized groups). */
263
- export async function addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
242
+ /** Authorize one user `user_id` (global across authorized groups). */
243
+ export async function addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
264
244
  return mutateAccess(
265
245
  ACCESS_USERS_ROUTE,
266
246
  'POST',
267
- name === undefined || name === '' ? { openId } : { openId, name },
247
+ name === undefined || name === '' ? { userId } : { userId, name },
268
248
  )
269
249
  }
270
250
 
271
251
  /** Revoke one user authorization (takes effect on the next message). */
272
- export async function removeAccessUser(openId: string): Promise<AccessMutationResponse> {
273
- return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', { openId })
252
+ export async function removeAccessUser(userId: string): Promise<AccessMutationResponse> {
253
+ return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', { userId })
274
254
  }
275
255
 
276
256
  /** Settle one pending authorization request (same resolver as the Feishu card). */