@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.
@@ -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
- /** The two credential slots the panel may write. */
235
- export type CredentialField = 'appId' | 'appSecret'
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, both whitelists, and the still-pending authorization requests.
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, nothing to reconnect.
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
@@ -3,8 +3,10 @@
3
3
  * credentials(PUT,DELETE) / reconnect / access。
4
4
  *
5
5
  * 路由注册进宿主可选的 `webServer` 服务(headless profile 没有它时面板自然
6
- * 不存在,飞书通道不受影响)。写操作额外做同源校验 —— 本机非回环绑定下
7
- * 防止跨站页面改写插件配置或授权白名单;读取不敏感,保持只读 GET。凭据值
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
- /** Wrap a handler so thrown errors become JSON instead of a dropped socket. */
155
- function guarded(handler: WebHandler): WebHandler {
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(server: HostWebServer, surface: SettingsSurface): () => void {
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 或 appSecret' })
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. The optional
421
- * `ctx.inject` keeps headless profiles from waiting on a service they will
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
- if (server === undefined) return
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)
@@ -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<{ appId: CredentialInfo; appSecret: CredentialInfo }>
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 权限(或对应的只读权限)。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
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、bitable:app 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
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': 'tools',
405
- 'feishuTools.wikiWrite': 'tools',
406
- wikiRoots: 'tools',
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: { appId: CredentialInfo; appSecret: CredentialInfo } = {
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 ['appId', 'appSecret'] as const) {
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: '请至少填写一项凭据(App ID 或 App Secret)' }
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 !== 'appId' && field !== 'appSecret') {
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
- const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
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(() => this.target.addAccessUser(id.value, label.value))
1094
+ return this.mutateAccess(() => add(id.value, label.value))
1048
1095
  }
1049
1096
 
1050
- /** Revoke one user authorization (immediate: the next message re-evaluates). */
1051
- async removeAccessUser(userId: unknown): Promise<AccessMutationResponse> {
1052
- const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
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(() => this.target.removeAccessUser(id.value))
1103
+ return this.mutateAccess(() => remove(id.value))
1055
1104
  }
1056
1105
 
1057
1106
  /**