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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +73 -55
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +33 -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 +173 -287
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +3001 -2627
  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 +12 -12
  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 +115 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/client/api.ts +5 -25
  26. package/src/client/panel/access-card.tsx +24 -15
  27. package/src/client/panel/access-tab.tsx +3 -3
  28. package/src/client/panel/connection-tab.tsx +70 -1
  29. package/src/client/panel/index.tsx +11 -62
  30. package/src/client/panel/shared.tsx +0 -23
  31. package/src/client/panel/workspace-tab.tsx +2 -18
  32. package/src/client/styles.ts +2 -0
  33. package/src/commands/registry.ts +2 -2
  34. package/src/config.ts +25 -56
  35. package/src/domain.ts +28 -125
  36. package/src/feishu/inbound-attachments.ts +1 -1
  37. package/src/feishu/manifest.ts +8 -31
  38. package/src/feishu/message-read.ts +19 -22
  39. package/src/feishu/scopes.ts +171 -0
  40. package/src/feishu/send-file.ts +543 -0
  41. package/src/feishu/slash-commands.ts +134 -0
  42. package/src/feishu/tools.ts +72 -112
  43. package/src/feishu-channel.ts +270 -9
  44. package/src/index.ts +5 -4
  45. package/src/interactions/approvals.ts +187 -7
  46. package/src/interactions/pending.ts +3 -3
  47. package/src/routing/inbound.ts +73 -106
  48. package/src/service.ts +463 -438
  49. package/src/session/manager.ts +1 -1
  50. package/src/session/routes.ts +2 -3
  51. package/src/settings/contract.ts +68 -85
  52. package/src/settings/routes.ts +3 -17
  53. package/src/settings/surface.ts +37 -88
  54. package/src/streamer.ts +94 -5
  55. package/src/workspaces.ts +21 -29
  56. package/src/cleanup.ts +0 -546
  57. package/src/client/panel/cleanup-card.tsx +0 -106
  58. 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,115 @@
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.
8
+ *
9
+ * @module dsh-channels/cards/picker
10
+ */
11
+
12
+ import { columnSet, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
13
+
14
+ /** `decision` carried by every picker button. */
15
+ export const PICK_ACTION = 'command-pick'
16
+
17
+ /** Commands that answer their bare form with a picker card. */
18
+ export const PICKER_COMMANDS = ['model', 'effort', 'permissions'] as const
19
+ export type PickerCommand = typeof PICKER_COMMANDS[number]
20
+
21
+ /** Cap on rendered choices so one picker cannot approach the card budget. */
22
+ export const MAX_PICKER_OPTIONS = 12
23
+
24
+ /** Callback value of one picker button. */
25
+ export interface PickAction {
26
+ decision: typeof PICK_ACTION
27
+ command: PickerCommand
28
+ /** Argument appended to the command line (`/model <arg>`). */
29
+ arg: string
30
+ /** Session the picker was rendered for; a rebound topic makes it stale. */
31
+ sessionId: string
32
+ }
33
+
34
+ /** One selectable choice. */
35
+ export interface PickerOption {
36
+ label: string
37
+ /** Command argument; never contains whitespace. */
38
+ arg: string
39
+ }
40
+
41
+ /** One picker card's render input. */
42
+ export interface PickerCardInput {
43
+ title: string
44
+ command: PickerCommand
45
+ /** Markdown shown above the choices (the command's own text answer). */
46
+ text: string
47
+ sessionId: string
48
+ options: readonly PickerOption[]
49
+ /** `arg` of the active choice, rendered highlighted. */
50
+ current?: string
51
+ /** Add a 恢复默认 button that runs `/<command> reset`. */
52
+ reset?: boolean
53
+ }
54
+
55
+ function pickButton(input: PickerCardInput, label: string, arg: string): object {
56
+ const active = arg === input.current
57
+ const value: PickAction = { decision: PICK_ACTION, command: input.command, arg, sessionId: input.sessionId }
58
+ return textButton({
59
+ label: active ? `✅ ${label}` : label,
60
+ type: active ? 'primary' : 'default',
61
+ width: 'fill',
62
+ margin: '0px',
63
+ value,
64
+ })
65
+ }
66
+
67
+ /** Choices in two-column rows. */
68
+ function optionRows(input: PickerCardInput, options: readonly PickerOption[]): object[] {
69
+ const rows: object[] = []
70
+ for (let index = 0; index < options.length; index += 2) {
71
+ const pair = options.slice(index, index + 2).map(option => pickButton(input, option.label, option.arg))
72
+ rows.push(columnSet(pair))
73
+ }
74
+ return rows
75
+ }
76
+
77
+ /** Whether a value can be appended to a command line as one argument. */
78
+ export function isPickerArg(value: string): boolean {
79
+ return value !== '' && !/\s/u.test(value)
80
+ }
81
+
82
+ /**
83
+ * The picker card. Options whose argument cannot form one command-line token
84
+ * are skipped; when more than `MAX_PICKER_OPTIONS` remain the rest are
85
+ * reported as typed-only.
86
+ */
87
+ export function buildPickerCard(input: PickerCardInput): object {
88
+ const usable = input.options.filter(option => isPickerArg(option.arg))
89
+ const shown = usable.slice(0, MAX_PICKER_OPTIONS)
90
+ const elements: object[] = [markdown(input.text), ...optionRows(input, shown)]
91
+ if (usable.length > shown.length) {
92
+ elements.push(note(`另有 ${usable.length - shown.length} 项未展示,可直接输入 /${input.command} <id>。`))
93
+ }
94
+ if (input.reset === true) {
95
+ elements.push(textButton({
96
+ label: '恢复默认',
97
+ value: { decision: PICK_ACTION, command: input.command, arg: 'reset', sessionId: input.sessionId } satisfies PickAction,
98
+ }))
99
+ }
100
+ elements.push(divider())
101
+ elements.push(note(`点击即切换 · 仅发起该会话的用户可操作 · 🔑 ${input.sessionId}`))
102
+ return standaloneCard(`**${input.title}**`, elements)
103
+ }
104
+
105
+ /** Parse a card action value into a picker action; undefined for anything else. */
106
+ export function parsePickAction(value: unknown): PickAction | undefined {
107
+ if (typeof value !== 'object' || value === null) return undefined
108
+ const record = value as Record<string, unknown>
109
+ if (record.decision !== PICK_ACTION) return undefined
110
+ const { command, arg, sessionId } = record
111
+ if (typeof command !== 'string' || !(PICKER_COMMANDS as readonly string[]).includes(command)) return undefined
112
+ if (typeof arg !== 'string' || !isPickerArg(arg)) return undefined
113
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
114
+ return { decision: PICK_ACTION, command: command as PickerCommand, arg, sessionId }
115
+ }
@@ -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 {
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). */
@@ -44,8 +44,8 @@ export interface AccessCardProps {
44
44
  error: string | null
45
45
  onAddGroup: (chatId: string, name: string) => Promise<boolean>
46
46
  onRemoveGroup: (chatId: string) => void
47
- onAddUser: (openId: string, name: string) => Promise<boolean>
48
- onRemoveUser: (openId: string) => void
47
+ onAddUser: (userId: string, name: string) => Promise<boolean>
48
+ onRemoveUser: (userId: string) => void
49
49
  onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
50
50
  }
51
51
 
@@ -54,7 +54,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
54
54
  const { access } = panel
55
55
  const [groupChatId, setGroupChatId] = useState('')
56
56
  const [groupName, setGroupName] = useState('')
57
- const [userOpenId, setUserOpenId] = useState('')
57
+ const [userUserId, setUserUserId] = useState('')
58
58
  const [userName, setUserName] = useState('')
59
59
  const groups: readonly AccessGroupWire[] = access.groups
60
60
  const users: readonly AccessUserWire[] = access.users
@@ -71,9 +71,9 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
71
71
  }
72
72
 
73
73
  const submitUser = async (): Promise<void> => {
74
- if (userOpenId.trim() === '') return
75
- if (await onAddUser(userOpenId, userName)) {
76
- setUserOpenId('')
74
+ if (userUserId.trim() === '') return
75
+ if (await onAddUser(userUserId, userName)) {
76
+ setUserUserId('')
77
77
  setUserName('')
78
78
  }
79
79
  }
@@ -93,6 +93,15 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
93
93
  ? (
94
94
  <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
95
95
  <strong>{PANEL_COPY.accessAdminMissingWarning}</strong>
96
+ {access.admin.ownerLookupError === undefined ? null : <div>{access.admin.ownerLookupError}</div>}
97
+ </div>
98
+ )
99
+ : null}
100
+ {access.mode === 'guarded' && access.admin.source === 'owner' && access.admin.userId !== undefined
101
+ ? (
102
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
103
+ {PANEL_COPY.accessAdminOwnerNotice}
104
+ <span className="dsh-channels-cfg__mono">{access.admin.userId}</span>
96
105
  </div>
97
106
  )
98
107
  : null}
@@ -171,11 +180,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
171
180
  : (
172
181
  <ul className="dsh-channels-cfg__accessList">
173
182
  {users.map(user => (
174
- <li className="dsh-channels-cfg__accessRow" key={user.openId}>
183
+ <li className="dsh-channels-cfg__accessRow" key={user.userId}>
175
184
  <span className="dsh-channels-cfg__accessMain">
176
- <span>{user.name ?? user.openId}</span>
185
+ <span>{user.name ?? user.userId}</span>
177
186
  <span className="dsh-channels-cfg__accessMeta">
178
- <span className="dsh-channels-cfg__mono">{user.openId}</span>
187
+ <span className="dsh-channels-cfg__mono">{user.userId}</span>
179
188
  {` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
180
189
  </span>
181
190
  </span>
@@ -183,7 +192,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
183
192
  type="button"
184
193
  className="dsh-channels-cfg__btn"
185
194
  disabled={disabled}
186
- onClick={() => onRemoveUser(user.openId)}
195
+ onClick={() => onRemoveUser(user.userId)}
187
196
  >
188
197
  {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
189
198
  </button>
@@ -195,11 +204,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
195
204
  <input
196
205
  type="text"
197
206
  className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
198
- value={userOpenId}
199
- placeholder={PANEL_COPY.accessUserOpenIdPlaceholder}
200
- aria-label={PANEL_COPY.accessUserOpenIdPlaceholder}
207
+ value={userUserId}
208
+ placeholder={PANEL_COPY.accessUserIdPlaceholder}
209
+ aria-label={PANEL_COPY.accessUserIdPlaceholder}
201
210
  disabled={disabled}
202
- onChange={event => setUserOpenId(event.target.value)}
211
+ onChange={event => setUserUserId(event.target.value)}
203
212
  />
204
213
  <input
205
214
  type="text"
@@ -213,7 +222,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
213
222
  <button
214
223
  type="button"
215
224
  className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
216
- disabled={disabled || userOpenId.trim() === ''}
225
+ disabled={disabled || userUserId.trim() === ''}
217
226
  onClick={() => { void submitUser() }}
218
227
  >
219
228
  {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 「访问授权」tab:准入与授权字段表单(管理员 open_id / 准入模式 /
2
+ * 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
3
3
  * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
4
4
  * 增删是独立即时 mutation。
5
5
  *
@@ -17,8 +17,8 @@ export interface AccessTabProps {
17
17
  accessError: string | null
18
18
  onAddGroup: (chatId: string, name: string) => Promise<boolean>
19
19
  onRemoveGroup: (chatId: string) => void
20
- onAddUser: (openId: string, name: string) => Promise<boolean>
21
- onRemoveUser: (openId: string) => void
20
+ onAddUser: (userId: string, name: string) => Promise<boolean>
21
+ onRemoveUser: (userId: string) => void
22
22
  onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
23
23
  }
24
24