@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4
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 +72 -15
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7847 -5293
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/controller.ts +32 -1
- package/src/analyze/contract.ts +121 -0
- package/src/analyze/launcher.ts +353 -0
- package/src/analyze/server.ts +201 -0
- package/src/cards/analyze.ts +116 -0
- package/src/cards/index.ts +1 -0
- package/src/cards/streaming.ts +58 -0
- package/src/client/api.ts +15 -0
- package/src/client/panel/access-card.tsx +96 -70
- package/src/client/panel/analyze-token-card.tsx +70 -0
- package/src/client/panel/index.tsx +72 -10
- package/src/client/panel/sessions-tab.tsx +7 -4
- package/src/client/panel/shared.tsx +12 -0
- package/src/client/panel/wiki-tab.tsx +62 -0
- package/src/config.ts +52 -14
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +19 -1
- package/src/feishu/tools.ts +3 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/health.ts +12 -0
- package/src/host/agent.ts +5 -1
- package/src/index.ts +1 -1
- package/src/interactions/pending.ts +11 -2
- package/src/routing/inbound.ts +57 -14
- package/src/service.ts +161 -28
- package/src/settings/contract.ts +37 -6
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +69 -20
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
|
@@ -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
|
+
}
|
package/src/cards/index.ts
CHANGED
package/src/cards/streaming.ts
CHANGED
|
@@ -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,
|
|
@@ -54,10 +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 [userUserId, setUserUserId] = useState('')
|
|
58
|
-
const [userName, setUserName] = useState('')
|
|
59
57
|
const groups: readonly AccessGroupWire[] = access.groups
|
|
60
|
-
const users: readonly AccessUserWire[] = access.users
|
|
61
58
|
const requests: readonly AccessRequestWire[] = access.requests
|
|
62
59
|
const working = busy === 'access'
|
|
63
60
|
const disabled = busy !== null || locked || !access.loaded
|
|
@@ -70,14 +67,6 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
const submitUser = async (): Promise<void> => {
|
|
74
|
-
if (userUserId.trim() === '') return
|
|
75
|
-
if (await onAddUser(userUserId, userName)) {
|
|
76
|
-
setUserUserId('')
|
|
77
|
-
setUserName('')
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
70
|
return (
|
|
82
71
|
<section className="dsh-channels-cfg__card">
|
|
83
72
|
<div className="dsh-channels-cfg__cardHead">
|
|
@@ -170,65 +159,16 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
170
159
|
</div>
|
|
171
160
|
</div>
|
|
172
161
|
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<li className="dsh-channels-cfg__accessRow" key={user.userId}>
|
|
184
|
-
<span className="dsh-channels-cfg__accessMain">
|
|
185
|
-
<span>{user.name ?? user.userId}</span>
|
|
186
|
-
<span className="dsh-channels-cfg__accessMeta">
|
|
187
|
-
<span className="dsh-channels-cfg__mono">{user.userId}</span>
|
|
188
|
-
{` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
|
|
189
|
-
</span>
|
|
190
|
-
</span>
|
|
191
|
-
<button
|
|
192
|
-
type="button"
|
|
193
|
-
className="dsh-channels-cfg__btn"
|
|
194
|
-
disabled={disabled}
|
|
195
|
-
onClick={() => onRemoveUser(user.userId)}
|
|
196
|
-
>
|
|
197
|
-
{working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
|
|
198
|
-
</button>
|
|
199
|
-
</li>
|
|
200
|
-
))}
|
|
201
|
-
</ul>
|
|
202
|
-
)}
|
|
203
|
-
<div className="dsh-channels-cfg__accessAdd">
|
|
204
|
-
<input
|
|
205
|
-
type="text"
|
|
206
|
-
className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
|
|
207
|
-
value={userUserId}
|
|
208
|
-
placeholder={PANEL_COPY.accessUserIdPlaceholder}
|
|
209
|
-
aria-label={PANEL_COPY.accessUserIdPlaceholder}
|
|
210
|
-
disabled={disabled}
|
|
211
|
-
onChange={event => setUserUserId(event.target.value)}
|
|
212
|
-
/>
|
|
213
|
-
<input
|
|
214
|
-
type="text"
|
|
215
|
-
className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
|
|
216
|
-
value={userName}
|
|
217
|
-
placeholder={PANEL_COPY.accessUserNamePlaceholder}
|
|
218
|
-
aria-label={PANEL_COPY.accessUserNamePlaceholder}
|
|
219
|
-
disabled={disabled}
|
|
220
|
-
onChange={event => setUserName(event.target.value)}
|
|
221
|
-
/>
|
|
222
|
-
<button
|
|
223
|
-
type="button"
|
|
224
|
-
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
|
|
225
|
-
disabled={disabled || userUserId.trim() === ''}
|
|
226
|
-
onClick={() => { void submitUser() }}
|
|
227
|
-
>
|
|
228
|
-
{working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
|
|
229
|
-
</button>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
162
|
+
<UserWhitelist
|
|
163
|
+
title={PANEL_COPY.accessUsersTitle}
|
|
164
|
+
empty={PANEL_COPY.accessUsersEmpty}
|
|
165
|
+
addLabel={PANEL_COPY.accessAddUser}
|
|
166
|
+
users={access.users}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
working={working}
|
|
169
|
+
onAdd={onAddUser}
|
|
170
|
+
onRemove={onRemoveUser}
|
|
171
|
+
/>
|
|
232
172
|
|
|
233
173
|
<div className="dsh-channels-cfg__accessSection">
|
|
234
174
|
<div className="dsh-channels-cfg__labelRow">
|
|
@@ -301,3 +241,89 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
301
241
|
</section>
|
|
302
242
|
)
|
|
303
243
|
}
|
|
244
|
+
|
|
245
|
+
/** One user whitelist (user `user_id` list with add/remove), shared by the access and wiki tabs. */
|
|
246
|
+
export function UserWhitelist(props: {
|
|
247
|
+
title: string
|
|
248
|
+
empty: string
|
|
249
|
+
addLabel: string
|
|
250
|
+
users: readonly AccessUserWire[]
|
|
251
|
+
disabled: boolean
|
|
252
|
+
working: boolean
|
|
253
|
+
onAdd: (userId: string, name: string) => Promise<boolean>
|
|
254
|
+
onRemove: (userId: string) => void
|
|
255
|
+
}): JSX.Element {
|
|
256
|
+
const { title, empty, addLabel, users, disabled, working, onAdd, onRemove } = props
|
|
257
|
+
const [userUserId, setUserUserId] = useState('')
|
|
258
|
+
const [userName, setUserName] = useState('')
|
|
259
|
+
|
|
260
|
+
const submitUser = async (): Promise<void> => {
|
|
261
|
+
if (userUserId.trim() === '') return
|
|
262
|
+
if (await onAdd(userUserId, userName)) {
|
|
263
|
+
setUserUserId('')
|
|
264
|
+
setUserName('')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className="dsh-channels-cfg__accessSection">
|
|
270
|
+
<div className="dsh-channels-cfg__labelRow">
|
|
271
|
+
<span className="dsh-channels-cfg__label">{title}</span>
|
|
272
|
+
<span className="dsh-channels-cfg__cardMeta">{users.length} 个</span>
|
|
273
|
+
</div>
|
|
274
|
+
{users.length === 0
|
|
275
|
+
? <span className="dsh-channels-cfg__help">{empty}</span>
|
|
276
|
+
: (
|
|
277
|
+
<ul className="dsh-channels-cfg__accessList">
|
|
278
|
+
{users.map(user => (
|
|
279
|
+
<li className="dsh-channels-cfg__accessRow" key={user.userId}>
|
|
280
|
+
<span className="dsh-channels-cfg__accessMain">
|
|
281
|
+
<span>{user.name ?? user.userId}</span>
|
|
282
|
+
<span className="dsh-channels-cfg__accessMeta">
|
|
283
|
+
<span className="dsh-channels-cfg__mono">{user.userId}</span>
|
|
284
|
+
{` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
|
|
285
|
+
</span>
|
|
286
|
+
</span>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className="dsh-channels-cfg__btn"
|
|
290
|
+
disabled={disabled}
|
|
291
|
+
onClick={() => onRemove(user.userId)}
|
|
292
|
+
>
|
|
293
|
+
{working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
|
|
294
|
+
</button>
|
|
295
|
+
</li>
|
|
296
|
+
))}
|
|
297
|
+
</ul>
|
|
298
|
+
)}
|
|
299
|
+
<div className="dsh-channels-cfg__accessAdd">
|
|
300
|
+
<input
|
|
301
|
+
type="text"
|
|
302
|
+
className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
|
|
303
|
+
value={userUserId}
|
|
304
|
+
placeholder={PANEL_COPY.accessUserIdPlaceholder}
|
|
305
|
+
aria-label={PANEL_COPY.accessUserIdPlaceholder}
|
|
306
|
+
disabled={disabled}
|
|
307
|
+
onChange={event => setUserUserId(event.target.value)}
|
|
308
|
+
/>
|
|
309
|
+
<input
|
|
310
|
+
type="text"
|
|
311
|
+
className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
|
|
312
|
+
value={userName}
|
|
313
|
+
placeholder={PANEL_COPY.accessUserNamePlaceholder}
|
|
314
|
+
aria-label={PANEL_COPY.accessUserNamePlaceholder}
|
|
315
|
+
disabled={disabled}
|
|
316
|
+
onChange={event => setUserName(event.target.value)}
|
|
317
|
+
/>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
|
|
321
|
+
disabled={disabled || userUserId.trim() === ''}
|
|
322
|
+
onClick={() => { void submitUser() }}
|
|
323
|
+
>
|
|
324
|
+
{working ? PANEL_COPY.accessWorking : addLabel}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
)
|
|
329
|
+
}
|