@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.5

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 (46) hide show
  1. package/README.md +88 -27
  2. package/docs/attachment-pipeline-v2.md +1 -1
  3. package/docs/feishu-onboarding.md +3 -3
  4. package/lib/client.js +1427 -1338
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7958 -6710
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/number-field.ts +97 -0
  18. package/src/client/panel/access-tab.tsx +61 -20
  19. package/src/client/panel/advanced-tab.tsx +59 -68
  20. package/src/client/panel/connection-tab.tsx +147 -164
  21. package/src/client/panel/credentials-card.tsx +124 -87
  22. package/src/client/panel/fields-tab.tsx +27 -0
  23. package/src/client/panel/index.tsx +126 -55
  24. package/src/client/panel/shared.tsx +369 -252
  25. package/src/client/panel/whitelist.tsx +197 -0
  26. package/src/client/panel/wiki-tab.tsx +48 -0
  27. package/src/client/styles.ts +108 -91
  28. package/src/config.ts +30 -4
  29. package/src/domain.ts +55 -0
  30. package/src/feishu/scopes.ts +13 -7
  31. package/src/feishu/tools.ts +3 -1
  32. package/src/health.ts +12 -0
  33. package/src/host/agent.ts +5 -1
  34. package/src/index.ts +2 -2
  35. package/src/interactions/pending.ts +11 -2
  36. package/src/routing/inbound.ts +57 -14
  37. package/src/service.ts +161 -28
  38. package/src/settings/contract.ts +222 -146
  39. package/src/settings/routes.ts +66 -14
  40. package/src/settings/surface.ts +234 -203
  41. package/src/streamer.ts +156 -7
  42. package/src/workspaces.ts +31 -0
  43. package/src/client/panel/access-card.tsx +0 -303
  44. package/src/client/panel/model-tab.tsx +0 -23
  45. package/src/client/panel/sessions-tab.tsx +0 -21
  46. package/src/client/panel/workspace-tab.tsx +0 -24
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The analyze API's dedicated HTTP listener.
3
+ *
4
+ * Serves exactly `POST /dsh-channels/analyze` (see `./contract.ts`) on the
5
+ * `analyzeListen` address. This module owns the transport-level checks, in
6
+ * order: path, method, bearer token, `Idempotency-Key`, media type, body size,
7
+ * and JSON validation; everything after validation belongs to the
8
+ * {@link AnalyzeHandler}. Bodies, tokens, and prompts are never logged.
9
+ *
10
+ * @module dsh-channels/analyze/server
11
+ */
12
+
13
+ import { createHash, timingSafeEqual } from 'node:crypto'
14
+ import { createServer } from 'node:http'
15
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
16
+ import type { AddressInfo } from 'node:net'
17
+ import {
18
+ ANALYZE_MAX_BODY_BYTES, ANALYZE_ROUTE, IDEMPOTENCY_KEY_PATTERN, parseAnalyzeRequest,
19
+ } from './contract.ts'
20
+ import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
21
+
22
+ /** The business side of the listener. */
23
+ export interface AnalyzeHandler {
24
+ /**
25
+ * Resolve the expected bearer token for this request.
26
+ * @returns the token, or undefined when it is not configured.
27
+ */
28
+ token(): Promise<string | undefined>
29
+ /**
30
+ * Accept one validated request.
31
+ * @param request - the validated body.
32
+ * @param idempotencyKey - the request's `Idempotency-Key`.
33
+ * @returns the HTTP answer.
34
+ */
35
+ analyze(request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse>
36
+ }
37
+
38
+ /** A bound analyze listener. */
39
+ export interface AnalyzeServer {
40
+ /** The bound address (the real port when `0` was requested). */
41
+ readonly address: AddressInfo
42
+ /** Stop accepting connections and close idle ones. */
43
+ close(): Promise<void>
44
+ }
45
+
46
+ class HttpError extends Error {
47
+ constructor(readonly status: number, message: string) {
48
+ super(message)
49
+ }
50
+ }
51
+
52
+ function send(res: ServerResponse, response: AnalyzeResponse): void {
53
+ const body = JSON.stringify(response.body)
54
+ res.writeHead(response.status, {
55
+ 'content-type': 'application/json; charset=utf-8',
56
+ 'cache-control': 'no-store',
57
+ 'content-length': Buffer.byteLength(body),
58
+ })
59
+ res.end(body)
60
+ }
61
+
62
+ function refuse(res: ServerResponse, status: number, error: string, headers: Record<string, string> = {}): void {
63
+ for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
64
+ send(res, { status, body: { ok: false, error } })
65
+ }
66
+
67
+ /** Constant-time token comparison over fixed-length digests. */
68
+ function tokenMatches(presented: string, expected: string): boolean {
69
+ const left = createHash('sha256').update(presented).digest()
70
+ const right = createHash('sha256').update(expected).digest()
71
+ return timingSafeEqual(left, right)
72
+ }
73
+
74
+ /** The bearer credential of one request, or undefined. */
75
+ function bearerOf(req: IncomingMessage): string | undefined {
76
+ const header = req.headers.authorization
77
+ if (header === undefined) return undefined
78
+ const match = /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)
79
+ return match?.[1]
80
+ }
81
+
82
+ /** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
83
+ async function readBody(req: IncomingMessage): Promise<string> {
84
+ const declared = Number(req.headers['content-length'])
85
+ if (Number.isFinite(declared) && declared > ANALYZE_MAX_BODY_BYTES) {
86
+ throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
87
+ }
88
+ const chunks: Buffer[] = []
89
+ let size = 0
90
+ for await (const chunk of req) {
91
+ const buffer = chunk as Buffer
92
+ size += buffer.length
93
+ if (size > ANALYZE_MAX_BODY_BYTES) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
94
+ chunks.push(buffer)
95
+ }
96
+ return Buffer.concat(chunks).toString('utf8')
97
+ }
98
+
99
+ /**
100
+ * Handle one HTTP request against the analyze contract.
101
+ * @param req - the incoming request.
102
+ * @param res - its response.
103
+ * @param handler - the business side.
104
+ */
105
+ export async function handleAnalyzeHttp(
106
+ req: IncomingMessage,
107
+ res: ServerResponse,
108
+ handler: AnalyzeHandler,
109
+ ): Promise<void> {
110
+ const pathname = new URL(req.url ?? '/', 'http://analyze.invalid').pathname
111
+ if (pathname !== ANALYZE_ROUTE) {
112
+ refuse(res, 404, '未知路径')
113
+ return
114
+ }
115
+ if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
116
+ refuse(res, 405, '只接受 POST', { allow: 'POST' })
117
+ return
118
+ }
119
+ const expected = await handler.token()
120
+ if (expected === undefined || expected === '') {
121
+ refuse(res, 503, '分析接口未配置访问令牌,请联系部署者配置 analyzeToken 引用的凭据')
122
+ return
123
+ }
124
+ const presented = bearerOf(req)
125
+ if (presented === undefined || !tokenMatches(presented, expected)) {
126
+ refuse(res, 401, '访问令牌无效', { 'www-authenticate': 'Bearer' })
127
+ return
128
+ }
129
+ const idempotencyKey = req.headers['idempotency-key']
130
+ if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
131
+ refuse(res, 400, '缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)')
132
+ return
133
+ }
134
+ const mediaType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
135
+ if (mediaType !== 'application/json') {
136
+ refuse(res, 415, 'Content-Type 必须是 application/json')
137
+ return
138
+ }
139
+ let raw: unknown
140
+ try {
141
+ raw = JSON.parse(await readBody(req))
142
+ } catch (error) {
143
+ if (error instanceof HttpError) {
144
+ refuse(res, error.status, error.message)
145
+ return
146
+ }
147
+ refuse(res, 400, '请求体不是合法的 JSON')
148
+ return
149
+ }
150
+ const parsed = parseAnalyzeRequest(raw)
151
+ if (!parsed.ok) {
152
+ refuse(res, 400, parsed.error)
153
+ return
154
+ }
155
+ send(res, await handler.analyze(parsed.value, idempotencyKey))
156
+ }
157
+
158
+ /** Split `host:port` (`[v6]:port` allowed) into listen arguments. */
159
+ export function parseListenAddress(listen: string): { host: string; port: number } {
160
+ const separator = listen.lastIndexOf(':')
161
+ const host = listen.slice(0, separator).replace(/^\[(.*)\]$/u, '$1')
162
+ return { host, port: Number(listen.slice(separator + 1)) }
163
+ }
164
+
165
+ /**
166
+ * Bind the analyze listener.
167
+ * @param listen - `host:port` address (`analyzeListen`).
168
+ * @param handler - the business side.
169
+ * @param warn - diagnostics sink for unexpected handler failures.
170
+ * @returns the bound server; rejects when the address cannot be bound.
171
+ */
172
+ export async function startAnalyzeServer(
173
+ listen: string,
174
+ handler: AnalyzeHandler,
175
+ warn: (message: string, ...args: unknown[]) => void,
176
+ ): Promise<AnalyzeServer> {
177
+ const server: Server = createServer((req, res) => {
178
+ handleAnalyzeHttp(req, res, handler).catch((error: unknown) => {
179
+ warn('dsh-channels: analyze request failed: %s', String(error))
180
+ if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
181
+ else res.destroy()
182
+ })
183
+ })
184
+ const { host, port } = parseListenAddress(listen)
185
+ await new Promise<void>((resolve, reject) => {
186
+ const onError = (error: Error): void => { reject(error) }
187
+ server.once('error', onError)
188
+ server.listen(port, host, () => {
189
+ server.off('error', onError)
190
+ resolve()
191
+ })
192
+ })
193
+ server.on('error', (error) => { warn('dsh-channels: analyze listener error: %s', String(error)) })
194
+ return {
195
+ address: server.address() as AddressInfo,
196
+ close: () => new Promise<void>((resolve) => {
197
+ server.close(() => { resolve() })
198
+ server.closeIdleConnections()
199
+ }),
200
+ }
201
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Root card of a log-platform analysis: who started which analysis when, the
3
+ * log-platform detail pages behind it, and a status tag (分析中 → 已完成 /
4
+ * 已停止 / 失败 / 已中断). The analysis itself streams in the topic below the
5
+ * card through the ordinary streaming card.
6
+ *
7
+ * @module dsh-channels/cards/analyze
8
+ */
9
+
10
+ import { CARD_CONFIG, CARD_SCHEMA, markdown, note } from './primitives.ts'
11
+
12
+ /** One log-platform detail page listed on the root card. */
13
+ export interface AnalyzeLogDetail {
14
+ readonly name: string
15
+ readonly url: string
16
+ }
17
+
18
+ /** Subject of one analysis, as the root card names it. */
19
+ export interface AnalyzeSubject {
20
+ /** Requester user_id (rendered as an @-mention). */
21
+ readonly requesterUserId: string
22
+ /** Trigger time as the platform sent it (shown verbatim). */
23
+ readonly triggeredAt: string
24
+ readonly app: string
25
+ readonly errorCode: string
26
+ readonly logDetails: readonly AnalyzeLogDetail[]
27
+ }
28
+
29
+ /**
30
+ * Root card status: `running` until the first turn settles (`done`,
31
+ * `stopped`, `error`), `failed` when the analysis could not start, or
32
+ * `interrupted` when DSH restarted before the first turn settled.
33
+ */
34
+ export type AnalyzeStatus =
35
+ | { readonly kind: 'running' }
36
+ | { readonly kind: 'done' }
37
+ | { readonly kind: 'stopped' }
38
+ | { readonly kind: 'error' }
39
+ | { readonly kind: 'failed'; readonly reason: string }
40
+ | { readonly kind: 'interrupted' }
41
+
42
+ /** Rows per table page (Feishu caps `page_size` at 10). */
43
+ const TABLE_PAGE_SIZE = 10
44
+
45
+ /** Escape markdown emphasis characters in platform-supplied text. */
46
+ function plain(text: string): string {
47
+ return text.replace(/([*_~`[\]\\])/gu, '\\$1')
48
+ }
49
+
50
+ /** Percent-encode the characters that would end a markdown link target early. */
51
+ function linkTarget(url: string): string {
52
+ return url.replace(/[()\s]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
53
+ }
54
+
55
+ /** Header tag text, tag color, and header template of each status. */
56
+ function statusLook(status: AnalyzeStatus): { tag: string; color: string; template: string } {
57
+ switch (status.kind) {
58
+ case 'running': return { tag: '分析中', color: 'blue', template: 'blue' }
59
+ case 'done': return { tag: '已完成', color: 'green', template: 'green' }
60
+ case 'stopped': return { tag: '已停止', color: 'neutral', template: 'grey' }
61
+ case 'error':
62
+ case 'failed': return { tag: '失败', color: 'red', template: 'red' }
63
+ case 'interrupted': return { tag: '已中断', color: 'orange', template: 'orange' }
64
+ }
65
+ }
66
+
67
+ /** Footer line of each status. */
68
+ function statusNote(status: AnalyzeStatus): string {
69
+ switch (status.kind) {
70
+ case 'running': return '分析过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。'
71
+ case 'done': return '分析已完成,结果见话题;在话题内回复即可继续追问。'
72
+ case 'stopped': return '分析已停止;在话题内回复即可继续。'
73
+ case 'error': return '分析回合失败,原因见话题;在话题内回复即可重试或追问。'
74
+ case 'failed': return `分析未能开始:${plain(status.reason)}`
75
+ case 'interrupted': return 'DSH 重启导致分析中断;在话题内回复即可继续。'
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The analysis root card.
81
+ * @param subject - the analysis subject.
82
+ * @param status - the status the card shows.
83
+ * @returns card JSON.
84
+ */
85
+ export function buildAnalyzeRootCard(subject: AnalyzeSubject, status: AnalyzeStatus): object {
86
+ const look = statusLook(status)
87
+ return {
88
+ schema: CARD_SCHEMA,
89
+ config: CARD_CONFIG,
90
+ header: {
91
+ title: { tag: 'plain_text', content: '日志分析' },
92
+ template: look.template,
93
+ text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: look.tag }, color: look.color }],
94
+ },
95
+ body: {
96
+ elements: [
97
+ markdown(`<at id=${subject.requesterUserId}></at> 于 ${plain(subject.triggeredAt)} 发起了对 **${plain(subject.app)}** 应用 **${plain(subject.errorCode)}** 错误码不良日志的分析。`),
98
+ {
99
+ tag: 'table',
100
+ page_size: TABLE_PAGE_SIZE,
101
+ row_height: 'low',
102
+ header_style: { background_style: 'grey', bold: true, lines: 1 },
103
+ columns: [
104
+ { name: 'name', display_name: '日志', data_type: 'text', width: 'auto' },
105
+ { name: 'link', display_name: '详情页', data_type: 'markdown', width: 'auto' },
106
+ ],
107
+ rows: subject.logDetails.map(detail => ({
108
+ name: detail.name,
109
+ link: `[查看](${linkTarget(detail.url)})`,
110
+ })),
111
+ },
112
+ note(statusNote(status)),
113
+ ],
114
+ },
115
+ }
116
+ }
@@ -11,3 +11,4 @@ export * from './onboarding.ts'
11
11
  export * from './approval.ts'
12
12
  export * from './question.ts'
13
13
  export * from './picker.ts'
14
+ export * from './analyze.ts'
@@ -58,6 +58,9 @@ export const PANEL_TITLES: Record<PanelPhase, string> = {
58
58
  /** `decision` carried by the streaming card's stop button. */
59
59
  export const STOP_ACTION = 'turn-stop'
60
60
 
61
+ /** `decision` carried by a queued card's "add to the current reply" button. */
62
+ export const STEER_ACTION = 'turn-steer'
63
+
61
64
  /**
62
65
  * Stop control state on the streaming card: `ready` offers the button,
63
66
  * `stopping` shows it disabled after the owner clicked it.
@@ -146,6 +149,14 @@ export interface CardState {
146
149
  stop?: StopControl
147
150
  /** Add the owner-only notice under the stop button (a non-owner clicked it). */
148
151
  stopDenied?: boolean
152
+ /**
153
+ * The message waits behind another turn of the same session: the main card
154
+ * renders only the queue notice and the steer button, never the panel,
155
+ * body or footer. Ignored on continuation cards.
156
+ */
157
+ queued?: boolean
158
+ /** Add the owner-only notice under the steer button (a non-owner clicked it). */
159
+ queuedDenied?: boolean
149
160
  stats: CardStats
150
161
  }
151
162
 
@@ -399,6 +410,31 @@ function stopElements(stop: StopControl, denied: boolean): object[] {
399
410
  return elements
400
411
  }
401
412
 
413
+ /** Body of a card whose message waits behind the session's running turn. */
414
+ export const QUEUED_NOTICE = '⏳ 排队中:上一轮回复结束后再处理这条消息'
415
+
416
+ /** Label of the queued card's button that moves the message into the running turn. */
417
+ export const STEER_LABEL = '⚡ 立即补充到当前回复'
418
+
419
+ /** Body of a queued card after its message moved into the running turn. */
420
+ export const MERGED_NOTICE = '↪️ 已补充到当前回复:当前这一步结束后模型就会读到这条消息,回复继续在上一张卡片中输出'
421
+
422
+ /** The queued card: notice, steer button and the optional owner-only notice. */
423
+ function queuedElements(denied: boolean): object[] {
424
+ const elements: object[] = [
425
+ markdown(QUEUED_NOTICE),
426
+ textButton({
427
+ label: STEER_LABEL,
428
+ type: 'default',
429
+ width: 'default',
430
+ margin: '8px 0 0 0',
431
+ value: { decision: STEER_ACTION },
432
+ }),
433
+ ]
434
+ if (denied) elements.push(note('ℹ️ 仅发起该会话的用户可补充'))
435
+ return elements
436
+ }
437
+
402
438
  // ---- rendering ------------------------------------------------------------
403
439
 
404
440
  /** Render one card. `reasoning`/`body` are the already-budgeted texts. */
@@ -410,6 +446,7 @@ function renderCard(
410
446
  ): object {
411
447
  const segment = options.segment
412
448
  const main = segment === undefined
449
+ if (main && state.queued === true) return card(queuedElements(state.queuedDenied === true))
413
450
  const elements: object[] = []
414
451
 
415
452
  if (main) {
@@ -629,3 +666,24 @@ export function initialStreamingCard(sessionId: string): object {
629
666
  }
630
667
  return buildCard(state)
631
668
  }
669
+
670
+ /**
671
+ * The first frame for a message that waits behind the session's running turn:
672
+ * the queue notice plus the button that moves the message into that turn.
673
+ * The streamer re-renders it as a normal streaming card once the message's
674
+ * own turn becomes the active one.
675
+ */
676
+ export function queuedStreamingCard(sessionId: string): object {
677
+ return buildCard({
678
+ phase: 'thinking',
679
+ reasoning: '',
680
+ body: '',
681
+ queued: true,
682
+ stats: { sessionId, inputTokens: 0, outputTokens: 0 },
683
+ })
684
+ }
685
+
686
+ /** Terminal card of a queued message that moved into the running turn. */
687
+ export function buildMergedCard(): object {
688
+ return card([markdown(MERGED_NOTICE)])
689
+ }
package/src/client/api.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ACCESS_REQUESTS_ROUTE,
11
11
  ACCESS_ROUTE,
12
12
  ACCESS_USERS_ROUTE,
13
+ ACCESS_WIKI_USERS_ROUTE,
13
14
  CATALOG_ROUTE,
14
15
  CONFIG_ROUTE,
15
16
  CREDENTIALS_ROUTE,
@@ -253,6 +254,20 @@ export async function removeAccessUser(userId: string): Promise<AccessMutationRe
253
254
  return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', { userId })
254
255
  }
255
256
 
257
+ /** Add one user `user_id` to the wiki whitelist. */
258
+ export async function addWikiUser(userId: string, name?: string): Promise<AccessMutationResponse> {
259
+ return mutateAccess(
260
+ ACCESS_WIKI_USERS_ROUTE,
261
+ 'POST',
262
+ name === undefined || name === '' ? { userId } : { userId, name },
263
+ )
264
+ }
265
+
266
+ /** Remove one user from the wiki whitelist (takes effect on the next wiki tool call). */
267
+ export async function removeWikiUser(userId: string): Promise<AccessMutationResponse> {
268
+ return mutateAccess(ACCESS_WIKI_USERS_ROUTE, 'DELETE', { userId })
269
+ }
270
+
256
271
  /** Settle one pending authorization request (same resolver as the Feishu card). */
257
272
  export async function resolveAccessRequest(
258
273
  token: string,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 浏览器半区的 `number` 字段辅助:按 Host 描述的 `scale` 在显示单位与存储
3
+ * 单位之间换算、保存前校验上下界,以及字节数的 MiB 读数。
4
+ *
5
+ * 不含 JSX/React,便于单测。Host 保存时按 config schema 复核。
6
+ *
7
+ * @module dsh-channels/client/number-field
8
+ */
9
+
10
+ import { fillCopy, PANEL_COPY } from '../settings/contract.ts'
11
+ import type { ConfigValue, EditableFieldWire } from '../settings/contract.ts'
12
+
13
+ /** Drop float noise from `stored / scale` (e.g. 0.30000000000000004). */
14
+ function tidy(value: number): number {
15
+ return Number(value.toPrecision(12))
16
+ }
17
+
18
+ /**
19
+ * Value the input shows for one field.
20
+ *
21
+ * @param field - The Host descriptor (`kind: 'number'`).
22
+ * @param value - Draft (the typed string) or effective value (stored units).
23
+ * @returns The input text.
24
+ */
25
+ export function numberInputValue(field: EditableFieldWire, value: ConfigValue | undefined): string {
26
+ if (typeof value === 'string') return value
27
+ if (typeof value !== 'number') return ''
28
+ return String(field.scale === undefined ? value : tidy(value / field.scale))
29
+ }
30
+
31
+ /**
32
+ * Input `step` in display units.
33
+ *
34
+ * @param field - The Host descriptor (`kind: 'number'`).
35
+ * @returns The step, or `undefined` when the Host gives none.
36
+ */
37
+ export function numberInputStep(field: EditableFieldWire): number | undefined {
38
+ if (field.step === undefined) return undefined
39
+ return field.scale === undefined ? field.step : tidy(field.step / field.scale)
40
+ }
41
+
42
+ /** One bound in display units with the field's unit appended. */
43
+ function boundLabel(field: EditableFieldWire, stored: number): string {
44
+ const shown = field.scale === undefined ? stored : tidy(stored / field.scale)
45
+ return field.unit === undefined ? String(shown) : `${shown} ${field.unit}`
46
+ }
47
+
48
+ /**
49
+ * Turn one number draft into its save payload, checking the Host bounds.
50
+ *
51
+ * @param field - The Host descriptor (`kind: 'number'`).
52
+ * @param raw - The draft: the typed string, or a stored-unit number.
53
+ * @returns The stored-unit value (`''` clears an optional field), or the reason it is rejected.
54
+ */
55
+ export function numberDraftValue(
56
+ field: EditableFieldWire,
57
+ raw: ConfigValue | undefined,
58
+ ): { ok: true; value: number | '' } | { ok: false; message: string } {
59
+ let stored: number
60
+ if (typeof raw === 'number') {
61
+ stored = raw
62
+ } else {
63
+ const text = typeof raw === 'string' ? raw.trim() : ''
64
+ if (text === '') {
65
+ return field.optional === true ? { ok: true, value: '' } : { ok: false, message: PANEL_COPY.numberInvalid }
66
+ }
67
+ const parsed = Number(text)
68
+ if (!Number.isFinite(parsed)) return { ok: false, message: PANEL_COPY.numberInvalid }
69
+ stored = field.scale === undefined ? parsed : Math.round(parsed * field.scale)
70
+ }
71
+ if (field.min !== undefined && stored < field.min) {
72
+ return { ok: false, message: fillCopy(PANEL_COPY.numberMin, { n: boundLabel(field, field.min) }) }
73
+ }
74
+ if (field.max !== undefined && stored > field.max) {
75
+ return { ok: false, message: fillCopy(PANEL_COPY.numberMax, { n: boundLabel(field, field.max) }) }
76
+ }
77
+ return { ok: true, value: stored }
78
+ }
79
+
80
+ /**
81
+ * Human reading of a byte count, e.g. `20 MiB`.
82
+ *
83
+ * @param text - The input text (bytes).
84
+ * @returns The reading, or `undefined` when the text is not a positive number.
85
+ */
86
+ export function bytesReading(text: string): string | undefined {
87
+ const bytes = Number(text)
88
+ if (text.trim() === '' || !Number.isFinite(bytes) || bytes <= 0) return undefined
89
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
90
+ let value = bytes
91
+ let unit = 0
92
+ while (value >= 1024 && unit < units.length - 1) {
93
+ value /= 1024
94
+ unit += 1
95
+ }
96
+ return `${Number(value.toFixed(value >= 100 || unit === 0 ? 0 : 1))} ${units[unit]}`
97
+ }
@@ -1,15 +1,18 @@
1
1
  /**
2
- * 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
3
- * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
4
- * 增删是独立即时 mutation。
2
+ * 「访问授权」tab:待处理申请(有才显示,置顶)、准入字段(调试模式 /
3
+ * 管理员 / 申请有效期)与群、用户白名单。字段保存走全局 draft,白名单增删
4
+ * 与申请处理是独立即时 mutation。
5
+ *
6
+ * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
7
+ * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
5
8
  *
6
9
  * @module dsh-channels/client/panel/access-tab
7
10
  */
8
11
 
9
- import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
- import { AccessCard } from './access-card.tsx'
11
- import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
12
+ import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
13
+ import { FieldSections, NoticeBar } from './shared.tsx'
12
14
  import type { FormView } from './shared.tsx'
15
+ import { RequestList, Whitelist } from './whitelist.tsx'
13
16
 
14
17
  export interface AccessTabProps {
15
18
  form: FormView
@@ -24,22 +27,60 @@ export interface AccessTabProps {
24
27
 
25
28
  export function AccessTab(props: AccessTabProps): JSX.Element {
26
29
  const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
30
+ const { access } = form.panel
31
+ const working = form.busy === 'access'
32
+ const disabled = form.busy !== null || form.locked || !access.loaded
33
+ // The administrator row says who is in charge right now when the owner fallback applies.
34
+ const ownerAdmin = access.mode === 'guarded' && access.admin.source === 'owner' ? access.admin.userId : undefined
35
+ const fields = ownerAdmin === undefined
36
+ ? form.fields
37
+ : form.fields.map(field => field.key === 'adminUserId'
38
+ ? { ...field, summary: fillCopy(PANEL_COPY.accessAdminOwnerSummary, { id: ownerAdmin }) }
39
+ : field)
27
40
  return (
28
41
  <div className="dsh-channels-cfg__tabBody">
29
- <TabHelp text={PANEL_COPY.accessTabHelp} />
30
- <FieldsCard form={form} groups={TAB_FIELD_GROUPS.access} />
31
- <SaveBar form={form} tab="access" />
32
- <AccessCard
33
- panel={form.panel}
34
- busy={form.busy}
35
- locked={form.locked}
36
- notice={accessNotice}
37
- error={accessError}
38
- onAddGroup={onAddGroup}
39
- onRemoveGroup={onRemoveGroup}
40
- onAddUser={onAddUser}
41
- onRemoveUser={onRemoveUser}
42
- onResolveRequest={onResolveRequest}
42
+ {accessNotice === null ? null : <NoticeBar kind="ok" text={accessNotice} />}
43
+ {accessError === null ? null : <NoticeBar kind="error" text={accessError} />}
44
+ {access.loaded && access.mode === 'guarded' && !access.admin.configured
45
+ ? (
46
+ <NoticeBar
47
+ kind="error"
48
+ text={access.admin.ownerLookupError === undefined
49
+ ? PANEL_COPY.accessAdminMissingWarning
50
+ : `${PANEL_COPY.accessAdminMissingWarning}(${access.admin.ownerLookupError})`}
51
+ />
52
+ )
53
+ : null}
54
+ {access.loaded && access.requests.length > 0
55
+ ? <RequestList requests={access.requests} disabled={disabled} working={working} onResolve={onResolveRequest} />
56
+ : null}
57
+ <FieldSections form={{ ...form, fields }} groups={TAB_FIELD_GROUPS.access} />
58
+ <Whitelist
59
+ title={PANEL_COPY.accessGroupsTitle}
60
+ entries={access.groups.map(group => ({ id: group.chatId, name: group.name, source: group.source }))}
61
+ avatar="群"
62
+ empty={PANEL_COPY.accessGroupsEmpty}
63
+ loaded={access.loaded}
64
+ addLabel={PANEL_COPY.accessAddGroup}
65
+ idPlaceholder={PANEL_COPY.accessGroupChatIdPlaceholder}
66
+ namePlaceholder={PANEL_COPY.accessGroupNamePlaceholder}
67
+ disabled={disabled}
68
+ working={working}
69
+ onAdd={onAddGroup}
70
+ onRemove={onRemoveGroup}
71
+ />
72
+ <Whitelist
73
+ title={PANEL_COPY.accessUsersTitle}
74
+ entries={access.users.map(user => ({ id: user.userId, name: user.name, source: user.source }))}
75
+ empty={PANEL_COPY.accessUsersEmpty}
76
+ loaded={access.loaded}
77
+ addLabel={PANEL_COPY.accessAddUser}
78
+ idPlaceholder={PANEL_COPY.accessUserIdPlaceholder}
79
+ namePlaceholder={PANEL_COPY.accessUserNamePlaceholder}
80
+ disabled={disabled}
81
+ working={working}
82
+ onAdd={onAddUser}
83
+ onRemove={onRemoveUser}
43
84
  />
44
85
  </div>
45
86
  )