@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.
- package/README.md +73 -55
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +33 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +173 -287
- package/lib/client.js.map +1 -1
- package/lib/index.js +3001 -2627
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +25 -56
- package/src/domain.ts +28 -125
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +19 -22
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu-channel.ts +270 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +463 -438
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +68 -85
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +37 -88
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
package/src/cards/approval.ts
CHANGED
|
@@ -8,8 +8,21 @@ import { columnSet, divider, markdown, note, standaloneCard, textButton } from '
|
|
|
8
8
|
|
|
9
9
|
// ---- HITL approval decision cards ----------------------------------------
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
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:
|
|
44
|
-
const
|
|
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
|
|
47
|
-
type
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
|
66
|
-
|
|
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
|
|
75
|
-
* enabled
|
|
76
|
-
* the session's latest streaming card (never
|
|
77
|
-
* card, whose full-replacement patches would
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
package/src/cards/index.ts
CHANGED
package/src/cards/onboarding.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/cards/streaming.ts
CHANGED
|
@@ -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
|
|
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.
|
|
478
|
-
*
|
|
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.
|
|
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
|
|
263
|
-
export async function addAccessUser(
|
|
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 === '' ? {
|
|
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(
|
|
273
|
-
return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', {
|
|
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: (
|
|
48
|
-
onRemoveUser: (
|
|
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 [
|
|
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 (
|
|
75
|
-
if (await onAddUser(
|
|
76
|
-
|
|
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.
|
|
183
|
+
<li className="dsh-channels-cfg__accessRow" key={user.userId}>
|
|
175
184
|
<span className="dsh-channels-cfg__accessMain">
|
|
176
|
-
<span>{user.name ?? user.
|
|
185
|
+
<span>{user.name ?? user.userId}</span>
|
|
177
186
|
<span className="dsh-channels-cfg__accessMeta">
|
|
178
|
-
<span className="dsh-channels-cfg__mono">{user.
|
|
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.
|
|
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={
|
|
199
|
-
placeholder={PANEL_COPY.
|
|
200
|
-
aria-label={PANEL_COPY.
|
|
207
|
+
value={userUserId}
|
|
208
|
+
placeholder={PANEL_COPY.accessUserIdPlaceholder}
|
|
209
|
+
aria-label={PANEL_COPY.accessUserIdPlaceholder}
|
|
201
210
|
disabled={disabled}
|
|
202
|
-
onChange={event =>
|
|
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 ||
|
|
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:准入与授权字段表单(管理员
|
|
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: (
|
|
21
|
-
onRemoveUser: (
|
|
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
|
|