@modesto_ma/dsh-channels 0.5.0-beta.3 → 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 +69 -12
- package/docs/feishu-onboarding.md +1 -1
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7549 -6353
- 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 +30 -4
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +13 -7
- package/src/feishu/tools.ts +3 -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 +65 -16
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
package/src/settings/contract.ts
CHANGED
|
@@ -20,12 +20,14 @@ export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
|
|
|
20
20
|
export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
|
|
21
21
|
/** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
|
|
22
22
|
export const CATALOG_ROUTE = '/dsh-channels/catalog'
|
|
23
|
-
/** 访问与授权只读快照(GET/HEAD
|
|
23
|
+
/** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户/知识库白名单、待处理申请。 */
|
|
24
24
|
export const ACCESS_ROUTE = '/dsh-channels/access'
|
|
25
25
|
/** 群白名单增删(POST/DELETE,body `{ chatId, name? }` / `{ chatId }`)。 */
|
|
26
26
|
export const ACCESS_GROUPS_ROUTE = '/dsh-channels/access/groups'
|
|
27
27
|
/** 用户白名单增删(POST/DELETE,body `{ userId, name? }` / `{ userId }`)。 */
|
|
28
28
|
export const ACCESS_USERS_ROUTE = '/dsh-channels/access/users'
|
|
29
|
+
/** 知识库白名单增删(POST/DELETE,body `{ userId, name? }` / `{ userId }`)。 */
|
|
30
|
+
export const ACCESS_WIKI_USERS_ROUTE = '/dsh-channels/access/wiki-users'
|
|
29
31
|
/** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
|
|
30
32
|
export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
|
|
31
33
|
/**
|
|
@@ -61,6 +63,8 @@ export const EDITABLE_KEYS = [
|
|
|
61
63
|
'welcomeOnboarding',
|
|
62
64
|
'slashCommandPanel',
|
|
63
65
|
'allowPermissionSwitch',
|
|
66
|
+
// 日志平台分析接口(访问令牌是凭据引用 analyzeToken,值写在 DSH 凭据里)
|
|
67
|
+
'analyzeListen',
|
|
64
68
|
// 准入与授权(管理员/准入模式/申请有效期;白名单由「访问与授权」卡片管理)
|
|
65
69
|
'adminUserId',
|
|
66
70
|
'accessMode',
|
|
@@ -107,6 +111,7 @@ export const RESTART_KEYS = [
|
|
|
107
111
|
'dmMode',
|
|
108
112
|
'requireMention',
|
|
109
113
|
'slashCommandPanel',
|
|
114
|
+
'analyzeListen',
|
|
110
115
|
'refreshMs',
|
|
111
116
|
'statusRefreshMs',
|
|
112
117
|
'loggerLevel',
|
|
@@ -135,7 +140,7 @@ export type StringListItemFormat = 'wiki-root-link'
|
|
|
135
140
|
* panel tab (see {@link TAB_FIELD_GROUPS}); the Host is the source of truth for
|
|
136
141
|
* which key belongs to which group.
|
|
137
142
|
*/
|
|
138
|
-
export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'tools' | 'model' | 'streaming' | 'access'
|
|
143
|
+
export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'tools' | 'model' | 'streaming' | 'access' | 'wiki'
|
|
139
144
|
|
|
140
145
|
/** One selectable value rendered by the generic form. */
|
|
141
146
|
export interface FieldOptionWire {
|
|
@@ -231,8 +236,11 @@ export interface CredentialRefWire {
|
|
|
231
236
|
source?: string
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
/**
|
|
235
|
-
|
|
239
|
+
/**
|
|
240
|
+
* The credential slots the panel may write: the Feishu application pair
|
|
241
|
+
* (resolved on connect) and the analyze API token (resolved per request).
|
|
242
|
+
*/
|
|
243
|
+
export type CredentialField = 'appId' | 'appSecret' | 'analyzeToken'
|
|
236
244
|
|
|
237
245
|
/** Official-SDK WebSocket lifecycle snapshot (structural copy, no SDK import on the wire). */
|
|
238
246
|
export interface ConnectionWire {
|
|
@@ -340,6 +348,7 @@ export interface PanelConfigWire {
|
|
|
340
348
|
credentials: {
|
|
341
349
|
appId: CredentialRefWire
|
|
342
350
|
appSecret: CredentialRefWire
|
|
351
|
+
analyzeToken: CredentialRefWire
|
|
343
352
|
}
|
|
344
353
|
}
|
|
345
354
|
|
|
@@ -388,7 +397,8 @@ export interface AccessRequestWire {
|
|
|
388
397
|
|
|
389
398
|
/**
|
|
390
399
|
* `GET /dsh-channels/access` payload: admission mode, the administrator
|
|
391
|
-
* reference,
|
|
400
|
+
* reference, the group, user, and wiki whitelists, and the still-pending
|
|
401
|
+
* authorization requests.
|
|
392
402
|
*/
|
|
393
403
|
export interface AccessWire {
|
|
394
404
|
mode: 'open' | 'guarded'
|
|
@@ -410,6 +420,8 @@ export interface AccessWire {
|
|
|
410
420
|
loaded: boolean
|
|
411
421
|
groups: AccessGroupWire[]
|
|
412
422
|
users: AccessUserWire[]
|
|
423
|
+
/** Wiki whitelist (`/auth wiki add`): topic owners allowed to use the wiki tools. */
|
|
424
|
+
wikiUsers: AccessUserWire[]
|
|
413
425
|
requests: AccessRequestWire[]
|
|
414
426
|
}
|
|
415
427
|
|
|
@@ -472,6 +484,7 @@ export type ClearConfigResponse =
|
|
|
472
484
|
export interface SaveCredentialsRequest {
|
|
473
485
|
appId?: string
|
|
474
486
|
appSecret?: string
|
|
487
|
+
analyzeToken?: string
|
|
475
488
|
}
|
|
476
489
|
|
|
477
490
|
/** `DELETE /dsh-channels/credentials` body. */
|
|
@@ -484,7 +497,8 @@ export interface ClearCredentialRequest {
|
|
|
484
497
|
*
|
|
485
498
|
* - `auto`: the channel was stopped and connected immediately after the save.
|
|
486
499
|
* - `pending`: the channel is running; reconnect for the value to take effect.
|
|
487
|
-
* - `not-needed`: removed while stopped,
|
|
500
|
+
* - `not-needed`: removed while stopped, or only the analyze API token
|
|
501
|
+
* changed (it applies to the next request), nothing to reconnect.
|
|
488
502
|
* - `unconfigured`: saved, but the other credential of the pair is still
|
|
489
503
|
* missing — the plugin keeps waiting (no connection attempt) until it is
|
|
490
504
|
* configured.
|
|
@@ -520,6 +534,7 @@ export const PANEL_TABS = [
|
|
|
520
534
|
{ id: 'model', label: '模型' },
|
|
521
535
|
{ id: 'workspace', label: '工作区' },
|
|
522
536
|
{ id: 'access', label: '访问授权' },
|
|
537
|
+
{ id: 'wiki', label: '知识库' },
|
|
523
538
|
{ id: 'advanced', label: '高级' },
|
|
524
539
|
] as const
|
|
525
540
|
|
|
@@ -532,6 +547,7 @@ export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]
|
|
|
532
547
|
model: ['model'],
|
|
533
548
|
workspace: ['workspace'],
|
|
534
549
|
access: ['access'],
|
|
550
|
+
wiki: ['wiki'],
|
|
535
551
|
advanced: ['streaming'],
|
|
536
552
|
}
|
|
537
553
|
|
|
@@ -544,6 +560,7 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
|
|
|
544
560
|
model: '模型与推理',
|
|
545
561
|
streaming: '流式卡片与日志',
|
|
546
562
|
access: '准入与授权',
|
|
563
|
+
wiki: '知识库工具',
|
|
547
564
|
}
|
|
548
565
|
|
|
549
566
|
/** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
|
|
@@ -632,6 +649,7 @@ export const PANEL_COPY = {
|
|
|
632
649
|
modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
|
|
633
650
|
workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录。插件不会自动删除工作区或话题目录;过期目录可手动删除,话题下次对话时会重建空目录。',
|
|
634
651
|
accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
|
|
652
|
+
wikiTabHelp: '知识库工具以应用身份访问「知识库范围」内的文档、电子表格和多维表格。只有管理员或知识库白名单里的用户发起的话题能用;每次打开、新建、修改仍需话题发起人在授权卡上确认。',
|
|
635
653
|
advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
|
|
636
654
|
save: '保存更改',
|
|
637
655
|
saving: '保存中…',
|
|
@@ -670,6 +688,13 @@ export const PANEL_COPY = {
|
|
|
670
688
|
enabled: '已启用',
|
|
671
689
|
disabled: '已关闭',
|
|
672
690
|
credentialRemoveConfirm: '确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。',
|
|
691
|
+
analyzeTokenCard: '日志分析接口令牌',
|
|
692
|
+
analyzeTokenCardHelp: '日志平台调用分析接口时 Authorization: Bearer 后面的值。经 DSH 凭据服务写入本机凭据库,面板永不回显;保存或移除后下一次请求即生效,无需重连。未配置时接口对所有请求返回 503。',
|
|
693
|
+
analyzeTokenLabel: '访问令牌',
|
|
694
|
+
analyzeTokenPlaceholder: '填写新令牌(建议 32 位以上随机串)',
|
|
695
|
+
saveAnalyzeToken: '保存令牌',
|
|
696
|
+
analyzeTokenSaved: '令牌已保存,下一次请求生效',
|
|
697
|
+
analyzeTokenRemoved: '令牌已移除,接口将拒绝所有请求',
|
|
673
698
|
accessNotLoaded: '飞书连接建立后加载授权列表',
|
|
674
699
|
loading: '加载中…',
|
|
675
700
|
loadFailed: '设置面板加载失败',
|
|
@@ -719,5 +744,11 @@ export const PANEL_COPY = {
|
|
|
719
744
|
accessRequestAllowed: '已授权',
|
|
720
745
|
accessRequestDenied: '已忽略',
|
|
721
746
|
accessHints: '三种授权入口:群内 /auth 命令、入群与用户授权卡、本面板。撤销立即生效;白名单只约束非管理员(管理员在任何私聊/群聊都直接放行),非管理员需要群和用户两层同时命中。',
|
|
747
|
+
// ---- wiki ---------------------------------------------------------------
|
|
748
|
+
wikiUsersCard: '知识库白名单',
|
|
749
|
+
wikiUsersHelp: '与「访问授权」里的用户白名单相互独立:这里的用户(以及管理员)发起的话题可以使用知识库工具。也可以在飞书里发送 /auth wiki add @用户 或 /auth wiki remove @用户;移除立即生效。',
|
|
750
|
+
wikiUsersEmpty: '知识库白名单为空:目前只有管理员发起的话题能使用知识库工具。',
|
|
751
|
+
wikiUserAdded: '已加入知识库白名单',
|
|
752
|
+
wikiUserRemoved: '已移出知识库白名单',
|
|
722
753
|
accessChatIdHint: 'chat_id 可从入群群信息卡或群内 /groupid 获取;user_id 可私聊机器人发送 /userid 获取。',
|
|
723
754
|
} as const
|
package/src/settings/routes.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* credentials(PUT,DELETE) / reconnect / access。
|
|
4
4
|
*
|
|
5
5
|
* 路由注册进宿主可选的 `webServer` 服务(headless profile 没有它时面板自然
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 不存在,飞书通道不受影响)。每个路由(读写都算)先交给宿主 `connection`
|
|
7
|
+
* 服务的 `requestRejection`:与 DSH 自己的 `/api` 同一套 Host/Origin 校验和
|
|
8
|
+
* 浏览器登录 cookie 鉴权,未通过返回 403 / 401,请求体不会被读取。没有
|
|
9
|
+
* `connection` 服务的组合不挂载这些路由。写操作另保留同源校验。凭据值
|
|
8
10
|
* 只走写路径,响应永远不包含它们。`catalog` 只读宿主 LLM / preset roster 的
|
|
9
11
|
* 枚举结果,用于面板的 provider → model → 推理档位级联选择。`access` 提供
|
|
10
12
|
* 准入快照与白名单/授权申请的写入口(与飞书卡片共用同一结算函数)。
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
ACCESS_REQUESTS_ROUTE,
|
|
20
22
|
ACCESS_ROUTE,
|
|
21
23
|
ACCESS_USERS_ROUTE,
|
|
24
|
+
ACCESS_WIKI_USERS_ROUTE,
|
|
22
25
|
CATALOG_ROUTE,
|
|
23
26
|
CONFIG_ROUTE,
|
|
24
27
|
CREDENTIALS_ROUTE,
|
|
@@ -44,6 +47,18 @@ export interface HostWebServer {
|
|
|
44
47
|
}): () => void
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The narrow `ctx.connection` surface this module consumes: DSH's own
|
|
52
|
+
* Host/Origin fence plus browser (login cookie) or API authentication.
|
|
53
|
+
*/
|
|
54
|
+
export interface HostRequestGate {
|
|
55
|
+
/**
|
|
56
|
+
* @param request - incoming request headers.
|
|
57
|
+
* @returns 403 (untrusted Host/Origin), 401 (unauthenticated), or undefined when allowed.
|
|
58
|
+
*/
|
|
59
|
+
requestRejection(request: { readonly headers: IncomingMessage['headers'] }): 401 | 403 | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
type WebHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
48
63
|
|
|
49
64
|
/** Request body cap: the panel payload is a handful of primitives. */
|
|
@@ -151,9 +166,20 @@ async function readAccessBody(
|
|
|
151
166
|
return body as Record<string, unknown>
|
|
152
167
|
}
|
|
153
168
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Wrap a handler so the host gate runs before anything else and thrown
|
|
171
|
+
* errors become JSON instead of a dropped socket.
|
|
172
|
+
*/
|
|
173
|
+
function guardedBy(gate: HostRequestGate, handler: WebHandler): WebHandler {
|
|
156
174
|
return async (req, res) => {
|
|
175
|
+
const rejection = gate.requestRejection(req)
|
|
176
|
+
if (rejection !== undefined) {
|
|
177
|
+
sendJson(res, rejection, {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: rejection === 401 ? '未登录 DSH,请从 DSH 启动链接打开页面' : '请求来源不受信任',
|
|
180
|
+
})
|
|
181
|
+
return
|
|
182
|
+
}
|
|
157
183
|
try {
|
|
158
184
|
await handler(req, res)
|
|
159
185
|
} catch (error) {
|
|
@@ -175,7 +201,12 @@ function forbiddenWrite(res: ServerResponse): void {
|
|
|
175
201
|
}
|
|
176
202
|
|
|
177
203
|
/** Register the panel routes; the returned disposer removes all of them. */
|
|
178
|
-
export function registerSettingsRoutes(
|
|
204
|
+
export function registerSettingsRoutes(
|
|
205
|
+
server: HostWebServer,
|
|
206
|
+
gate: HostRequestGate,
|
|
207
|
+
surface: SettingsSurface,
|
|
208
|
+
): () => void {
|
|
209
|
+
const guarded = (handler: WebHandler): WebHandler => guardedBy(gate, handler)
|
|
179
210
|
const disposers = [
|
|
180
211
|
server.register({
|
|
181
212
|
kind: 'exact',
|
|
@@ -272,15 +303,15 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
|
|
|
272
303
|
}
|
|
273
304
|
throw error
|
|
274
305
|
}
|
|
275
|
-
const payload = body as { appId?: unknown; appSecret?: unknown; field?: unknown } | null
|
|
306
|
+
const payload = body as { appId?: unknown; appSecret?: unknown; analyzeToken?: unknown; field?: unknown } | null
|
|
276
307
|
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
277
308
|
sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
|
|
278
309
|
return
|
|
279
310
|
}
|
|
280
311
|
if (methodOf(req) === 'DELETE') {
|
|
281
312
|
const field = payload.field
|
|
282
|
-
if (field !== 'appId' && field !== 'appSecret') {
|
|
283
|
-
sendJson(res, 400, { ok: false, error: 'field 必须是 appId 或
|
|
313
|
+
if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
|
|
314
|
+
sendJson(res, 400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
|
|
284
315
|
return
|
|
285
316
|
}
|
|
286
317
|
const result = await surface.clearCredential(field as CredentialField)
|
|
@@ -290,6 +321,7 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
|
|
|
290
321
|
const values: SaveCredentialsRequest = {}
|
|
291
322
|
if (payload.appId !== undefined) values.appId = payload.appId as string
|
|
292
323
|
if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
|
|
324
|
+
if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
|
|
293
325
|
const result = await surface.saveCredentials(values)
|
|
294
326
|
sendJson(res, result.ok ? 200 : 400, result)
|
|
295
327
|
}),
|
|
@@ -395,6 +427,23 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
|
|
|
395
427
|
sendJson(res, result.ok ? 200 : 400, result)
|
|
396
428
|
}),
|
|
397
429
|
}),
|
|
430
|
+
server.register({
|
|
431
|
+
kind: 'exact',
|
|
432
|
+
path: ACCESS_WIKI_USERS_ROUTE,
|
|
433
|
+
handler: guarded(async (req, res) => {
|
|
434
|
+
if (!allowOnly(req, res, ['POST', 'DELETE'])) return
|
|
435
|
+
if (!isSameOriginRequest(req)) {
|
|
436
|
+
forbiddenWrite(res)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
const payload = await readAccessBody(req, res)
|
|
440
|
+
if (payload === undefined) return
|
|
441
|
+
const result = methodOf(req) === 'DELETE'
|
|
442
|
+
? await surface.removeWikiUser(payload.userId)
|
|
443
|
+
: await surface.saveWikiUser(payload.userId, payload.name)
|
|
444
|
+
sendJson(res, result.ok ? 200 : 400, result)
|
|
445
|
+
}),
|
|
446
|
+
}),
|
|
398
447
|
server.register({
|
|
399
448
|
kind: 'exact',
|
|
400
449
|
path: ACCESS_REQUESTS_ROUTE,
|
|
@@ -417,17 +466,20 @@ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsS
|
|
|
417
466
|
}
|
|
418
467
|
|
|
419
468
|
/**
|
|
420
|
-
* Mount the routes once the host carries a Web server
|
|
421
|
-
* `
|
|
469
|
+
* Mount the routes once the host carries both a Web server and the
|
|
470
|
+
* `connection` service that authenticates its browser requests. The optional
|
|
471
|
+
* `ctx.inject` keeps headless profiles from waiting on services they will
|
|
422
472
|
* never have; embedded/test hosts that resolve services synchronously fall
|
|
423
|
-
* back to the direct attach path.
|
|
473
|
+
* back to the direct attach path. Without `connection` nothing is mounted, so
|
|
474
|
+
* the panel never serves unauthenticated requests.
|
|
424
475
|
*/
|
|
425
476
|
export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface): void {
|
|
426
477
|
const attach = (webCtx: HostContext): void => {
|
|
427
478
|
const server = webCtx.get?.('webServer') as HostWebServer | undefined
|
|
428
|
-
|
|
479
|
+
const gate = webCtx.get?.('connection') as HostRequestGate | undefined
|
|
480
|
+
if (server === undefined || gate === undefined) return
|
|
429
481
|
webCtx.effect(
|
|
430
|
-
() => registerSettingsRoutes(server, surface),
|
|
482
|
+
() => registerSettingsRoutes(server, gate, surface),
|
|
431
483
|
'dsh-channels: settings panel routes',
|
|
432
484
|
)
|
|
433
485
|
}
|
|
@@ -435,7 +487,7 @@ export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface):
|
|
|
435
487
|
inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
|
|
436
488
|
}).inject
|
|
437
489
|
if (typeof inject === 'function') {
|
|
438
|
-
inject.call(ctx, ['webServer'], attach)
|
|
490
|
+
inject.call(ctx, ['webServer', 'connection'], attach)
|
|
439
491
|
return
|
|
440
492
|
}
|
|
441
493
|
attach(ctx)
|
package/src/settings/surface.ts
CHANGED
|
@@ -95,7 +95,7 @@ export interface SettingsTarget {
|
|
|
95
95
|
runningConfigSnapshot(): FeishuConfig | undefined
|
|
96
96
|
applyOverlay(overlay: OverlayPatch): void
|
|
97
97
|
restart(): Promise<void>
|
|
98
|
-
credentialStates(): Promise<
|
|
98
|
+
credentialStates(): Promise<Record<CredentialField, CredentialInfo>>
|
|
99
99
|
setCredential(field: CredentialField, value: string): Promise<void>
|
|
100
100
|
clearCredential(field: CredentialField): Promise<void>
|
|
101
101
|
/** Current admission snapshot: mode, administrator, whitelists, pending requests. */
|
|
@@ -108,6 +108,10 @@ export interface SettingsTarget {
|
|
|
108
108
|
addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse>
|
|
109
109
|
/** Revoke one user authorization (takes effect on the next message). */
|
|
110
110
|
removeAccessUser(userId: string): Promise<AccessMutationResponse>
|
|
111
|
+
/** Add one user `user_id` to the wiki whitelist. */
|
|
112
|
+
addWikiUser(userId: string, name?: string): Promise<AccessMutationResponse>
|
|
113
|
+
/** Remove one user from the wiki whitelist (takes effect on the next wiki tool call). */
|
|
114
|
+
removeWikiUser(userId: string): Promise<AccessMutationResponse>
|
|
111
115
|
/** Settle one pending request (panel shares this path with the Feishu DM card). */
|
|
112
116
|
resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
|
|
113
117
|
}
|
|
@@ -121,6 +125,9 @@ export interface CredentialInfo {
|
|
|
121
125
|
|
|
122
126
|
const RESTART_SET = new Set<string>(RESTART_KEYS)
|
|
123
127
|
|
|
128
|
+
/** Every credential slot the panel writes, in form order. */
|
|
129
|
+
const CREDENTIAL_FIELDS: readonly CredentialField[] = ['appId', 'appSecret', 'analyzeToken']
|
|
130
|
+
|
|
124
131
|
/** Optional text fields: an empty string in the panel clears the override. */
|
|
125
132
|
const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
|
|
126
133
|
'provider',
|
|
@@ -128,6 +135,7 @@ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
|
|
|
128
135
|
'reasoningEffort',
|
|
129
136
|
'agentPreset',
|
|
130
137
|
'adminUserId',
|
|
138
|
+
'analyzeListen',
|
|
131
139
|
])
|
|
132
140
|
|
|
133
141
|
const BOOLEAN_KEYS = new Set<EditableKey>([
|
|
@@ -250,6 +258,11 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
250
258
|
welcomeOnboarding: { label: '首次私聊引导卡', help: '每个私聊会话第一次触达时发送一张命令与用法引导卡,只发一次。' },
|
|
251
259
|
slashCommandPanel: { label: '同步飞书原生斜杠指令面板', help: '连接时把插件命令同步到飞书输入 / 时弹出的面板;需开通 application:app_slash_command:read 与 write 权限,部分企业不允许应用添加斜杠指令。关闭时不读写面板,直接输入命令不受影响。' },
|
|
252
260
|
allowPermissionSwitch: { label: '允许飞书侧切换权限预设', help: '安全敏感:开启后话题所有者可用 /permissions 切换沙箱与审批预设。' },
|
|
261
|
+
analyzeListen: {
|
|
262
|
+
label: '日志分析接口监听地址',
|
|
263
|
+
help: '日志平台调用 POST /dsh-channels/analyze 的独立端口,格式 host:port(如 0.0.0.0:3090);留空 = 关闭接口;需重连生效。请求需带 Authorization: Bearer <令牌>,令牌在本页下方「日志分析接口令牌」卡片填写。',
|
|
264
|
+
placeholder: '留空 = 关闭',
|
|
265
|
+
},
|
|
253
266
|
adminUserId: {
|
|
254
267
|
label: '管理员 user_id',
|
|
255
268
|
help: '唯一管理员。私聊机器人发送 /userid 获取(不是 ou_ 开头的 open_id,也不是邮箱);留空 = 清除 Web 覆盖。未配置时自动使用飞书应用所有者(需可选的「获取应用信息」权限,开不了就在这里手动填写);两者都没有时 guarded 模式会拒绝所有对话。',
|
|
@@ -354,11 +367,11 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
354
367
|
},
|
|
355
368
|
'feishuTools.wikiRead': {
|
|
356
369
|
label: '启用知识库读取工具',
|
|
357
|
-
help: '让模型浏览「知识库范围」内的知识库节点,并读取 docx 文档、电子表格和多维表格(feishu_wiki_list / feishu_wiki_read / feishu_wiki_sheet_read / feishu_wiki_base_read),以应用身份访问。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app
|
|
370
|
+
help: '让模型浏览「知识库范围」内的知识库节点,并读取 docx 文档、电子表格和多维表格(feishu_wiki_list / feishu_wiki_read / feishu_wiki_sheet_read / feishu_wiki_base_read),以应用身份访问。需开通 wiki:wiki、docx:document(或对应的只读权限),电子表格需 sheets:spreadsheet:read,多维表格需 base:table:read、base:field:read、base:record:read(旧版 sheets:spreadsheet、bitable:app 不生效)。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
|
|
358
371
|
},
|
|
359
372
|
'feishuTools.wikiWrite': {
|
|
360
373
|
label: '启用知识库写入工具',
|
|
361
|
-
help: '让模型在「知识库范围」内新建 docx 文档、电子表格和多维表格,编辑本应用拥有的文档和电子表格,增改删本应用拥有的多维表格记录、向其他多维表格新增记录(feishu_wiki_create / feishu_wiki_edit / feishu_wiki_sheet_edit / feishu_wiki_base_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki、docx:document、sheets:spreadsheet、
|
|
374
|
+
help: '让模型在「知识库范围」内新建 docx 文档、电子表格和多维表格,编辑本应用拥有的文档和电子表格,增改删本应用拥有的多维表格记录、向其他多维表格新增记录(feishu_wiki_create / feishu_wiki_edit / feishu_wiki_sheet_edit / feishu_wiki_base_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki、docx:document、sheets:spreadsheet:write_only、base:record:create、base:record:update、base:record:delete 权限,以及读取工具的权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
|
|
362
375
|
},
|
|
363
376
|
wikiRoots: {
|
|
364
377
|
label: '知识库范围(wikiRoots)',
|
|
@@ -383,6 +396,7 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
383
396
|
welcomeOnboarding: 'behavior',
|
|
384
397
|
slashCommandPanel: 'behavior',
|
|
385
398
|
allowPermissionSwitch: 'behavior',
|
|
399
|
+
analyzeListen: 'behavior',
|
|
386
400
|
adminUserId: 'access',
|
|
387
401
|
accessMode: 'access',
|
|
388
402
|
authRequestTtlDays: 'access',
|
|
@@ -401,9 +415,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
401
415
|
'feishuTools.topicMessages': 'tools',
|
|
402
416
|
'feishuTools.topicMessagesTimeoutMs': 'tools',
|
|
403
417
|
'feishuTools.sendFile': 'tools',
|
|
404
|
-
'feishuTools.wikiRead': '
|
|
405
|
-
'feishuTools.wikiWrite': '
|
|
406
|
-
wikiRoots: '
|
|
418
|
+
'feishuTools.wikiRead': 'wiki',
|
|
419
|
+
'feishuTools.wikiWrite': 'wiki',
|
|
420
|
+
wikiRoots: 'wiki',
|
|
407
421
|
provider: 'model',
|
|
408
422
|
model: 'model',
|
|
409
423
|
reasoningEffort: 'model',
|
|
@@ -561,6 +575,9 @@ export function normalizeCredentialValue(raw: unknown): { ok: true; value: strin
|
|
|
561
575
|
const MAX_ACCESS_VALUE_LENGTH = 128
|
|
562
576
|
|
|
563
577
|
|
|
578
|
+
/** Format reason for a rejected user `user_id` (shared by the user and wiki whitelists). */
|
|
579
|
+
const USER_ID_FORMAT = '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)'
|
|
580
|
+
|
|
564
581
|
/** Trim one whitelist identifier, rejecting empty, oversized, or hostile input. */
|
|
565
582
|
export function normalizeAccessId(
|
|
566
583
|
raw: unknown,
|
|
@@ -754,9 +771,10 @@ export class SettingsSurface {
|
|
|
754
771
|
this.directoryPicker?.available === true,
|
|
755
772
|
)
|
|
756
773
|
|
|
757
|
-
let credentialStates:
|
|
774
|
+
let credentialStates: Record<CredentialField, CredentialInfo> = {
|
|
758
775
|
appId: { configured: false, writable: false },
|
|
759
776
|
appSecret: { configured: false, writable: false },
|
|
777
|
+
analyzeToken: { configured: false, writable: false },
|
|
760
778
|
}
|
|
761
779
|
try {
|
|
762
780
|
credentialStates = await this.target.credentialStates()
|
|
@@ -772,6 +790,7 @@ export class SettingsSurface {
|
|
|
772
790
|
const credentials: PanelConfigWire['credentials'] = {
|
|
773
791
|
appId: toWire(effective.appId, credentialStates.appId),
|
|
774
792
|
appSecret: toWire(effective.appSecret, credentialStates.appSecret),
|
|
793
|
+
analyzeToken: toWire(effective.analyzeToken, credentialStates.analyzeToken),
|
|
775
794
|
}
|
|
776
795
|
|
|
777
796
|
// The access snapshot comes from in-memory maps, but a broken seam must
|
|
@@ -788,6 +807,7 @@ export class SettingsSurface {
|
|
|
788
807
|
loaded: false,
|
|
789
808
|
groups: [],
|
|
790
809
|
users: [],
|
|
810
|
+
wikiUsers: [],
|
|
791
811
|
requests: [],
|
|
792
812
|
}
|
|
793
813
|
}
|
|
@@ -915,7 +935,7 @@ export class SettingsSurface {
|
|
|
915
935
|
if (busy !== undefined) return busy
|
|
916
936
|
const writes: Array<{ field: CredentialField; value: string }> = []
|
|
917
937
|
const issues: FieldIssueWire[] = []
|
|
918
|
-
for (const field of
|
|
938
|
+
for (const field of CREDENTIAL_FIELDS) {
|
|
919
939
|
const raw = values[field]
|
|
920
940
|
if (raw === undefined) continue
|
|
921
941
|
const normalized = normalizeCredentialValue(raw)
|
|
@@ -926,13 +946,16 @@ export class SettingsSurface {
|
|
|
926
946
|
writes.push({ field, value: normalized.value })
|
|
927
947
|
}
|
|
928
948
|
if (issues.length > 0) return { ok: false, error: '凭据校验失败', issues }
|
|
929
|
-
if (writes.length === 0) return { ok: false, error: '
|
|
949
|
+
if (writes.length === 0) return { ok: false, error: '请至少填写一项凭据' }
|
|
930
950
|
|
|
931
951
|
try {
|
|
932
952
|
for (const write of writes) await this.target.setCredential(write.field, write.value)
|
|
933
953
|
} catch (error) {
|
|
934
954
|
return { ok: false, error: messageOf(error) }
|
|
935
955
|
}
|
|
956
|
+
if (writes.every(write => write.field === 'analyzeToken')) {
|
|
957
|
+
return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
958
|
+
}
|
|
936
959
|
return this.finishCredentialChange('save')
|
|
937
960
|
}
|
|
938
961
|
|
|
@@ -940,7 +963,7 @@ export class SettingsSurface {
|
|
|
940
963
|
async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
|
|
941
964
|
const busy = this.writeBusy()
|
|
942
965
|
if (busy !== undefined) return busy
|
|
943
|
-
if (field
|
|
966
|
+
if (!CREDENTIAL_FIELDS.includes(field)) {
|
|
944
967
|
return { ok: false, error: '未知的凭据字段' }
|
|
945
968
|
}
|
|
946
969
|
try {
|
|
@@ -948,6 +971,7 @@ export class SettingsSurface {
|
|
|
948
971
|
} catch (error) {
|
|
949
972
|
return { ok: false, error: messageOf(error) }
|
|
950
973
|
}
|
|
974
|
+
if (field === 'analyzeToken') return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
951
975
|
return this.finishCredentialChange('clear')
|
|
952
976
|
}
|
|
953
977
|
|
|
@@ -1040,18 +1064,43 @@ export class SettingsSurface {
|
|
|
1040
1064
|
|
|
1041
1065
|
/** Authorize one user `user_id` (not an open_id or email). */
|
|
1042
1066
|
async saveAccessUser(userId: unknown, name?: unknown): Promise<AccessMutationResponse> {
|
|
1043
|
-
|
|
1067
|
+
return this.saveUser(userId, name, (id, label) => this.target.addAccessUser(id, label))
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Revoke one user authorization (immediate: the next message re-evaluates). */
|
|
1071
|
+
async removeAccessUser(userId: unknown): Promise<AccessMutationResponse> {
|
|
1072
|
+
return this.removeUser(userId, id => this.target.removeAccessUser(id))
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Add one user `user_id` to the wiki whitelist. */
|
|
1076
|
+
async saveWikiUser(userId: unknown, name?: unknown): Promise<AccessMutationResponse> {
|
|
1077
|
+
return this.saveUser(userId, name, (id, label) => this.target.addWikiUser(id, label))
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Remove one user from the wiki whitelist (immediate: the next wiki tool call re-evaluates). */
|
|
1081
|
+
async removeWikiUser(userId: unknown): Promise<AccessMutationResponse> {
|
|
1082
|
+
return this.removeUser(userId, id => this.target.removeWikiUser(id))
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private async saveUser(
|
|
1086
|
+
userId: unknown,
|
|
1087
|
+
name: unknown,
|
|
1088
|
+
add: (id: string, label: string | undefined) => Promise<AccessMutationResponse>,
|
|
1089
|
+
): Promise<AccessMutationResponse> {
|
|
1090
|
+
const id = normalizeAccessId(userId, '用户 user_id', USER_ID_FORMAT)
|
|
1044
1091
|
if (!id.ok) return { ok: false, error: id.message }
|
|
1045
1092
|
const label = normalizeAccessName(name)
|
|
1046
1093
|
if (!label.ok) return { ok: false, error: label.message }
|
|
1047
|
-
return this.mutateAccess(() =>
|
|
1094
|
+
return this.mutateAccess(() => add(id.value, label.value))
|
|
1048
1095
|
}
|
|
1049
1096
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1097
|
+
private async removeUser(
|
|
1098
|
+
userId: unknown,
|
|
1099
|
+
remove: (id: string) => Promise<AccessMutationResponse>,
|
|
1100
|
+
): Promise<AccessMutationResponse> {
|
|
1101
|
+
const id = normalizeAccessId(userId, '用户 user_id', USER_ID_FORMAT)
|
|
1053
1102
|
if (!id.ok) return { ok: false, error: id.message }
|
|
1054
|
-
return this.mutateAccess(() =>
|
|
1103
|
+
return this.mutateAccess(() => remove(id.value))
|
|
1055
1104
|
}
|
|
1056
1105
|
|
|
1057
1106
|
/**
|