@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.2

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.
@@ -12,6 +12,8 @@ import { createLarkChannel, LoggerLevel } from '@larksuiteoapi/node-sdk'
12
12
  import { SCOPE_GRANTED_STATUS } from './feishu/scopes.ts'
13
13
  import type { ScopeGrant } from './feishu/scopes.ts'
14
14
  import type { RemoteSlashCommand, SlashCommandApi, SlashCommandEntry } from './feishu/slash-commands.ts'
15
+ import { createWikiApi } from './feishu/wiki/api.ts'
16
+ import type { WikiApi, WikiRawRequest } from './feishu/wiki/api.ts'
15
17
  import type {
16
18
  BotAddedEvent,
17
19
  ChatInfo,
@@ -87,6 +89,12 @@ export interface FeishuChannel {
87
89
  * absence skips the panel sync.
88
90
  */
89
91
  readonly slashCommands?: SlashCommandApi
92
+ /**
93
+ * Wiki and docx endpoints used by the wiki tools, called with the app
94
+ * identity. Optional so stubs can omit it; absence fails wiki calls with
95
+ * a channel-unavailable result.
96
+ */
97
+ readonly wiki?: WikiApi
90
98
  /** Current official-SDK WebSocket lifecycle snapshot, when initialized. */
91
99
  getConnectionStatus(): FeishuConnectionStatus | undefined
92
100
  /** Start the WebSocket long connection (resolves after the handshake). */
@@ -361,6 +369,7 @@ class LarkChannelAdapter implements FeishuChannel {
361
369
  private readonly requireMention: boolean
362
370
  private readonly dmMode: 'open' | 'disabled'
363
371
  readonly slashCommands: SlashCommandApi
372
+ readonly wiki: WikiApi
364
373
 
365
374
  constructor(options: ChannelConnectOptions) {
366
375
  this.channel = createLarkChannel({
@@ -391,6 +400,7 @@ class LarkChannelAdapter implements FeishuChannel {
391
400
  this.dmMode = options.dmMode
392
401
  const rawClient = this.channel.rawClient
393
402
  this.slashCommands = slashCommandApi(async <T,>(input: Parameters<RawRequest>[0]) => await rawClient.request<T>(input))
403
+ this.wiki = createWikiApi(async <T,>(input: Parameters<WikiRawRequest>[0]) => await rawClient.request<T>(input))
394
404
  }
395
405
 
396
406
  onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
package/src/service.ts CHANGED
@@ -64,6 +64,8 @@ import { AttachmentIntake } from './feishu/inbound-attachments.ts'
64
64
  import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
65
65
  import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
66
66
  import { SendFileToolRuntime } from './feishu/send-file.ts'
67
+ import { WikiConsent } from './feishu/wiki/consent.ts'
68
+ import { WikiToolRuntime } from './feishu/wiki/tools.ts'
67
69
  import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
68
70
  import {
69
71
  MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
@@ -146,6 +148,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
146
148
  const accessGroupsRaw = domain.table('access_groups')
147
149
  const accessUsersRaw = domain.table('access_users')
148
150
  const accessRequestsRaw = domain.table('access_requests')
151
+ const accessWikiUsersRaw = domain.table('access_wiki_users')
149
152
  return {
150
153
  routes: {
151
154
  get: key => table.get(key) as RouteRecord | undefined,
@@ -189,12 +192,21 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
189
192
  delete: key => accessRequestsRaw.delete(key),
190
193
  entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
191
194
  },
195
+ accessWikiUsers: {
196
+ get: key => accessWikiUsersRaw.get(key) as AccessUserRecord | undefined,
197
+ put: (key, value) => accessWikiUsersRaw.put(key, value),
198
+ delete: key => accessWikiUsersRaw.delete(key),
199
+ entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
200
+ },
192
201
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
193
202
  setGlobal: value => domain.global.set(value),
194
203
  close: () => domain.close(),
195
204
  }
196
205
  }
197
206
 
207
+ /** `/auth wiki` usage line (input-error card). */
208
+ const AUTH_WIKI_USAGE = '用法:/auth wiki add <@用户|user_id> 或 /auth wiki remove <@用户|user_id>。知识库白名单里的用户(以及管理员)发起的话题可以使用知识库工具。'
209
+
198
210
  /**
199
211
  * Resolve one credential reference through the host credentials seam.
200
212
  *
@@ -268,6 +280,8 @@ export class FeishuService {
268
280
  private readonly attachmentFetchTools: AttachmentFetchToolRuntime
269
281
  private readonly topicMessagesTools: TopicMessagesToolRuntime
270
282
  private readonly sendFileTools: SendFileToolRuntime
283
+ private readonly wikiConsent: WikiConsent
284
+ private readonly wikiTools: WikiToolRuntime
271
285
  /** FIFO admission for ordinary turns and commands targeting one route. */
272
286
  private readonly routeOperations = new RouteOperationQueue()
273
287
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -378,6 +392,7 @@ export class FeishuService {
378
392
  }
379
393
  mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
380
394
  mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
395
+ mounts.push(this.wikiTools.mount(agentCtx, sessionId))
381
396
  return Promise.all(mounts).then(() => undefined)
382
397
  },
383
398
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -437,6 +452,21 @@ export class FeishuService {
437
452
  fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
438
453
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
439
454
  })
455
+ this.wikiConsent = new WikiConsent({
456
+ channel: () => (this.started ? this.channel : undefined),
457
+ timeoutMs: () => this.config.approvalTimeoutMs,
458
+ onError: (sessionId, error) => {
459
+ this.ctx.logger?.warn?.('dsh-channels wiki consent error (session %s): %s', sessionId, String(error))
460
+ },
461
+ })
462
+ this.wikiTools = new WikiToolRuntime({
463
+ config: () => this.config,
464
+ channel: () => (this.started ? this.channel : undefined),
465
+ routeOfSession: sessionId => this.approvalRouteFor(sessionId),
466
+ mayUseWiki: userId => this.accessController.isAdmin(userId) || this.accessStore.hasWikiUser(userId),
467
+ consent: this.wikiConsent,
468
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
469
+ })
440
470
  this.sendFileTools = new SendFileToolRuntime({
441
471
  config: () => this.config,
442
472
  channel: () => (this.started ? this.channel : undefined),
@@ -1168,6 +1198,8 @@ export class FeishuService {
1168
1198
  && this.appOwner !== undefined && 'error' in this.appOwner && this.appOwner.apiRejected,
1169
1199
  slashCommandPanel: this.config.slashCommandPanel,
1170
1200
  sendFile: this.config.feishuTools.sendFile,
1201
+ wikiRead: this.config.feishuTools.wikiRead && this.config.wikiRoots.length > 0,
1202
+ wikiWrite: this.config.feishuTools.wikiWrite && this.config.wikiRoots.length > 0,
1171
1203
  })
1172
1204
  state = result.consoleUrl === undefined
1173
1205
  ? { state: 'ok', checkedAt: Date.now() }
@@ -1383,6 +1415,7 @@ export class FeishuService {
1383
1415
  void accessRequests.onCardAction(event)
1384
1416
  answerer.onCardAction(event)
1385
1417
  questions.onCardAction(event)
1418
+ this.wikiConsent.onCardAction(event)
1386
1419
  this.onStopAction(event)
1387
1420
  void this.onPickAction(event).catch((error: unknown) => {
1388
1421
  this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
@@ -1504,6 +1537,7 @@ export class FeishuService {
1504
1537
  this.approvalAnswerer?.dispose()
1505
1538
  this.approvalAnswerer = undefined
1506
1539
  this.questionAnswerer?.dispose()
1540
+ this.wikiConsent.dispose()
1507
1541
  this.questionAnswerer = undefined
1508
1542
  if (this.gcTimer !== undefined) {
1509
1543
  clearInterval(this.gcTimer)
@@ -1651,8 +1685,8 @@ export class FeishuService {
1651
1685
  ...(msg.chatType === 'group'
1652
1686
  ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
1653
1687
  : []),
1654
- `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
1655
- '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`。',
1688
+ `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 知识库 ${this.accessStore.wikiUserRecords().length} 个 · 待处理申请 ${pending.length} 个`,
1689
+ '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`;知识库授权:`/auth wiki add @用户`。',
1656
1690
  ]), direct)
1657
1691
  return
1658
1692
  }
@@ -1735,6 +1769,45 @@ export class FeishuService {
1735
1769
  }, direct)
1736
1770
  return
1737
1771
  }
1772
+ if (action === 'wiki') {
1773
+ const sub = words[1]
1774
+ const target = sub === 'add' || sub === 'remove'
1775
+ ? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
1776
+ : { ok: false as const, reason: 'usage' as const }
1777
+ if (!target.ok) {
1778
+ await this.delivery.commandCard(msg, {
1779
+ kind: 'input-error', command: 'auth',
1780
+ text: target.reason === 'usage' ? AUTH_WIKI_USAGE : AUTH_USER_TARGET_ERRORS[target.reason],
1781
+ }, direct)
1782
+ return
1783
+ }
1784
+ const { userId } = target
1785
+ const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
1786
+ if (sub === 'add') {
1787
+ if (this.accessStore.hasWikiUser(userId)) {
1788
+ await this.delivery.commandCard(msg, {
1789
+ kind: 'success', command: 'auth', text: `该用户已在知识库白名单中:${label}。`,
1790
+ }, direct)
1791
+ return
1792
+ }
1793
+ await this.accessStore.addWikiUser({
1794
+ userId,
1795
+ ...(target.name === undefined ? {} : { name: target.name }),
1796
+ source: 'command', addedBy: msg.senderId, addedAt: now,
1797
+ })
1798
+ await this.delivery.commandCard(msg, {
1799
+ kind: 'success', command: 'auth',
1800
+ text: `✅ 已把 ${label} 加入知识库白名单:他发起的话题里可以使用知识库工具(每次读取、新建、修改仍需他在授权卡上确认)。`,
1801
+ }, direct)
1802
+ return
1803
+ }
1804
+ const removed = await this.accessStore.removeWikiUser(userId)
1805
+ await this.delivery.commandCard(msg, {
1806
+ kind: removed ? 'success' : 'unavailable', command: 'auth',
1807
+ text: removed ? `已把 ${label} 移出知识库白名单,立即生效。` : `用户 ${label} 不在知识库白名单中。`,
1808
+ }, direct)
1809
+ return
1810
+ }
1738
1811
  if (action === 'list') {
1739
1812
  const groups = this.accessStore.groupRecords()
1740
1813
  const users = this.accessStore.userRecords()
@@ -1745,18 +1818,24 @@ export class FeishuService {
1745
1818
  const userLines = users.length === 0
1746
1819
  ? ['(暂无)']
1747
1820
  : users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
1821
+ const wikiUsers = this.accessStore.wikiUserRecords()
1822
+ const wikiLines = wikiUsers.length === 0
1823
+ ? ['(暂无;管理员始终可用)']
1824
+ : wikiUsers.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
1748
1825
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
1749
1826
  `**已授权群(${groups.length})**`,
1750
1827
  ...groupLines,
1751
1828
  `**已授权用户(${users.length})**`,
1752
1829
  ...userLines,
1830
+ `**知识库白名单(${wikiUsers.length})**`,
1831
+ ...wikiLines,
1753
1832
  `**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`,
1754
1833
  ]), direct)
1755
1834
  return
1756
1835
  }
1757
1836
  await this.delivery.commandCard(msg, {
1758
1837
  kind: 'input-error', command: 'auth',
1759
- text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
1838
+ text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>|wiki add <@用户|user_id>|wiki remove <@用户|user_id>]',
1760
1839
  }, direct)
1761
1840
  } catch (error) {
1762
1841
  this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
@@ -1916,7 +1995,8 @@ export class FeishuService {
1916
1995
  /**
1917
1996
  * Picker-card click: run the chosen `/<command> <arg>` exactly as if the
1918
1997
  * operator had typed it under the picker card, so access admission, route
1919
- * ownership and validation are the typed command's. Stale pickers (the
1998
+ * ownership and validation are the typed command's. The result replaces
1999
+ * the picker card in place instead of arriving as a new card. Stale pickers (the
1920
2000
  * topic was rebound, or the card was forwarded elsewhere) and non-owner
1921
2001
  * clicks are ignored.
1922
2002
  */
@@ -1928,7 +2008,7 @@ export class FeishuService {
1928
2008
  const userId = event.operator.userId
1929
2009
  if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
1930
2010
  const line = `/${action.command} ${action.arg}`
1931
- await this.handleSlashMessage({
2011
+ await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
1932
2012
  messageId: event.messageId,
1933
2013
  chatId: route.chatId,
1934
2014
  chatType: route.chatType,
@@ -1941,7 +2021,7 @@ export class FeishuService {
1941
2021
  mentionedBot: true,
1942
2022
  ...(route.kind === 'thread' ? { threadId: route.key } : {}),
1943
2023
  createTime: Date.now(),
1944
- }, line)
2024
+ }, line))
1945
2025
  }
1946
2026
 
1947
2027
  /**
@@ -2157,7 +2237,7 @@ export class FeishuService {
2157
2237
  return
2158
2238
  }
2159
2239
  if (spec.name === 'health' && result.kind === 'success') {
2160
- await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()))
2240
+ await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread: false })
2161
2241
  return
2162
2242
  }
2163
2243
  if (spec.name === 'status' && result.kind === 'success') {
@@ -81,6 +81,10 @@ export const EDITABLE_KEYS = [
81
81
  'feishuTools.topicMessages',
82
82
  'feishuTools.topicMessagesTimeoutMs',
83
83
  'feishuTools.sendFile',
84
+ 'feishuTools.wikiRead',
85
+ 'feishuTools.wikiWrite',
86
+ // 知识库范围(顶层 `wikiRoots` 字符串列表;为空时不挂载任何知识库工具)
87
+ 'wikiRoots',
84
88
  'promptContextTimeoutMs',
85
89
  // 模型
86
90
  'provider',
@@ -110,8 +114,21 @@ export const RESTART_KEYS = [
110
114
 
111
115
  export type RestartKey = (typeof RESTART_KEYS)[number]
112
116
 
113
- /** Render kinds the generic panel form understands. */
114
- export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
117
+ /**
118
+ * Render kinds the generic panel form understands. `string-list` renders one
119
+ * single-line input per entry with a remove button each and an add button
120
+ * below; its value is a `readonly string[]`.
121
+ */
122
+ export type FieldKind = 'boolean' | 'number' | 'text' | 'select' | 'string-list'
123
+
124
+ /**
125
+ * Per-entry format of a `string-list` field. Host and Client both map the
126
+ * format onto the same entry parser, so an invalid entry is marked before
127
+ * saving and the Host re-validates it on save.
128
+ *
129
+ * - `wiki-root-link`: a Feishu wiki space link or wiki node link (`wikiRoots`).
130
+ */
131
+ export type StringListItemFormat = 'wiki-root-link'
115
132
 
116
133
  /**
117
134
  * Field groups partition the editable form. Each group is rendered inside one
@@ -139,7 +156,12 @@ export interface EditableFieldWire {
139
156
  min?: number
140
157
  max?: number
141
158
  step?: number
159
+ /** Input placeholder; for `string-list` it applies to every entry input. */
142
160
  placeholder?: string
161
+ /** `string-list` only: most non-empty entries the Host accepts. */
162
+ maxItems?: number
163
+ /** `string-list` only: format every non-empty entry must satisfy. */
164
+ itemFormat?: StringListItemFormat
143
165
  /** Render across the whole form width (long paths). */
144
166
  wide?: boolean
145
167
  /**
@@ -182,8 +204,13 @@ export interface CatalogWire {
182
204
  defaultPresetId?: string
183
205
  }
184
206
 
185
- /** Values the panel may submit. Empty strings for optional text fields clear the override. */
186
- export type ConfigValue = string | number | boolean
207
+ /**
208
+ * Values the panel may submit. Empty strings for optional text fields clear the
209
+ * override. A `string-list` field submits its entries as typed (untrimmed,
210
+ * empty inputs included) so Host issue indexes match the rendered inputs; the
211
+ * Host trims entries and drops empty ones before persisting.
212
+ */
213
+ export type ConfigValue = string | number | boolean | readonly string[]
187
214
  export type EditableValues = Partial<Record<EditableKey, ConfigValue>>
188
215
 
189
216
  /** Read-only facts shown in the advanced card. */
@@ -418,6 +445,11 @@ export interface SaveConfigRequest {
418
445
  export interface FieldIssueWire {
419
446
  key: string
420
447
  message: string
448
+ /**
449
+ * `string-list` only: zero-based position of the rejected entry in the
450
+ * submitted array (empty inputs included). Absent for a whole-field issue.
451
+ */
452
+ index?: number
421
453
  }
422
454
 
423
455
  /** `PUT /dsh-channels/config` response. */
@@ -607,6 +639,12 @@ export const PANEL_COPY = {
607
639
  discardTab: '放弃本页更改',
608
640
  saveHintClean: '没有未保存的更改。',
609
641
  dirtyCount: '未保存项',
642
+ listAdd: '添加',
643
+ listRemove: '移除',
644
+ listEntry: '第 {n} 项',
645
+ listTooMany: '最多 {max} 项(空输入框不计)',
646
+ listNotArray: '必须是字符串列表',
647
+ listEntryNotString: '必须是字符串',
610
648
  browseDirectory: '选择目录…',
611
649
  browsingDirectory: '选择中…',
612
650
  directoryPicked: '已选择目录',
@@ -13,6 +13,7 @@
13
13
  import { DSH_CHANNELS_VERSION } from '../session-export.ts'
14
14
  import type { FeishuConfig } from '../config.ts'
15
15
  import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
16
+ import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from '../feishu/wiki/links.ts'
16
17
  import type { HealthSnapshot } from '../health.ts'
17
18
  import { prepareWorkspaceBaseDir } from '../workspaces.ts'
18
19
  import {
@@ -45,6 +46,7 @@ import type {
45
46
  SaveCredentialsResponse,
46
47
  ScopeCheckWire,
47
48
  SlashCommandSyncWire,
49
+ StringListItemFormat,
48
50
  } from './contract.ts'
49
51
  import {
50
52
  configValueOf,
@@ -137,8 +139,77 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
137
139
  'feishuTools.attachmentFetch',
138
140
  'feishuTools.topicMessages',
139
141
  'feishuTools.sendFile',
142
+ 'feishuTools.wikiRead',
143
+ 'feishuTools.wikiWrite',
140
144
  ])
141
145
 
146
+ /** Bounds and entry format of one `string-list` field. */
147
+ interface StringListSpec {
148
+ /** Most non-empty entries accepted (mirrors the config schema bound). */
149
+ maxItems: number
150
+ itemFormat?: StringListItemFormat
151
+ }
152
+
153
+ /** Editable keys rendered as `string-list` (one input per entry). */
154
+ const STRING_LIST_FIELDS: Partial<Record<EditableKey, StringListSpec>> = {
155
+ wikiRoots: { maxItems: 20, itemFormat: 'wiki-root-link' },
156
+ }
157
+
158
+ /** Entry checks per item format: the readable reason, or `undefined` when valid. */
159
+ const ITEM_FORMAT_CHECKS: Readonly<Record<StringListItemFormat, (entry: string) => string | undefined>> = {
160
+ 'wiki-root-link': (entry) => {
161
+ const parsed = parseWikiRootLink(entry)
162
+ return parsed.ok ? undefined : WIKI_ROOT_LINK_ERRORS[parsed.error]
163
+ },
164
+ }
165
+
166
+ /** `第 N 项「entry」:reason`, shared wording with the Client check. */
167
+ function listEntryMessage(index: number, entry: string | undefined, reason: string): string {
168
+ const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
169
+ return entry === undefined ? `${position}${reason}` : `${position}「${entry}」:${reason}`
170
+ }
171
+
172
+ /**
173
+ * Normalize one submitted `string-list` value: entries are trimmed, empty
174
+ * entries dropped, the count bounded by `maxItems`, and every remaining entry
175
+ * checked against `itemFormat`. Issue indexes refer to the submitted array
176
+ * (empty inputs included), so the panel can mark the exact input.
177
+ *
178
+ * @param key - The editable key the issues are reported under.
179
+ * @param raw - The untrusted submitted value.
180
+ * @param spec - The field's bounds and entry format.
181
+ * @returns The cleaned entries, or every per-entry / whole-field issue.
182
+ */
183
+ export function normalizeStringList(
184
+ key: EditableKey,
185
+ raw: unknown,
186
+ spec: StringListSpec,
187
+ ): { ok: true; value: string[] } | { ok: false; issues: FieldIssueWire[] } {
188
+ if (!Array.isArray(raw)) return { ok: false, issues: [{ key, message: PANEL_COPY.listNotArray }] }
189
+ const issues: FieldIssueWire[] = []
190
+ const value: string[] = []
191
+ const check = spec.itemFormat === undefined ? undefined : ITEM_FORMAT_CHECKS[spec.itemFormat]
192
+ raw.forEach((item: unknown, index) => {
193
+ if (typeof item !== 'string') {
194
+ issues.push({ key, index, message: listEntryMessage(index, undefined, PANEL_COPY.listEntryNotString) })
195
+ return
196
+ }
197
+ const entry = item.trim()
198
+ if (entry === '') return
199
+ const reason = check?.(entry)
200
+ if (reason !== undefined) {
201
+ issues.push({ key, index, message: listEntryMessage(index, entry, reason) })
202
+ return
203
+ }
204
+ value.push(entry)
205
+ })
206
+ const count = raw.filter(item => typeof item !== 'string' || item.trim() !== '').length
207
+ if (count > spec.maxItems) {
208
+ issues.push({ key, message: PANEL_COPY.listTooMany.replace('{max}', String(spec.maxItems)) })
209
+ }
210
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, value }
211
+ }
212
+
142
213
  const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
143
214
  attachmentStore: [
144
215
  { value: 'native', label: 'DSH 原生附件库(默认)' },
@@ -281,6 +352,20 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
281
352
  label: '启用 feishu_send_file 工具',
282
353
  help: '让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。',
283
354
  },
355
+ 'feishuTools.wikiRead': {
356
+ label: '启用知识库读取工具',
357
+ help: '让模型浏览「知识库范围」内的知识库节点并读取 docx 文档(feishu_wiki_list / feishu_wiki_read),以应用身份访问。需开通 wiki:wiki(或 wiki:wiki:readonly)与 docx:document(或 docx:document:readonly)权限。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
358
+ },
359
+ 'feishuTools.wikiWrite': {
360
+ label: '启用知识库写入工具',
361
+ help: '让模型在「知识库范围」内新建 docx 文档,并编辑本应用拥有的文档(feishu_wiki_create / feishu_wiki_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki 与 docx:document 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
362
+ },
363
+ wikiRoots: {
364
+ label: '知识库范围(wikiRoots)',
365
+ help: '知识库工具只能访问这些位置,每个输入框填一个链接:整个知识空间 https://<租户>.feishu.cn/wiki/space/<space_id>,或节点 https://<租户>.feishu.cn/wiki/<node_token>(该节点及其下所有节点);最多 20 项,空输入框保存时忽略。留空 = 不挂载任何知识库工具(即使上面的开关已开启)。对新会话生效。',
366
+ placeholder: 'https://<租户>.feishu.cn/wiki/space/<space_id>',
367
+ wide: true,
368
+ },
284
369
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
285
370
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
286
371
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -316,6 +401,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
316
401
  'feishuTools.topicMessages': 'tools',
317
402
  'feishuTools.topicMessagesTimeoutMs': 'tools',
318
403
  'feishuTools.sendFile': 'tools',
404
+ 'feishuTools.wikiRead': 'tools',
405
+ 'feishuTools.wikiWrite': 'tools',
406
+ wikiRoots: 'tools',
319
407
  provider: 'model',
320
408
  model: 'model',
321
409
  reasoningEffort: 'model',
@@ -329,10 +417,13 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
329
417
  export const FIELD_DESCRIPTORS: readonly EditableFieldWire[] = EDITABLE_KEYS.map(key => {
330
418
  const meta = FIELD_META[key]
331
419
  const options = SELECT_OPTIONS[key]
420
+ const list = STRING_LIST_FIELDS[key]
332
421
  return {
333
422
  key,
334
423
  label: meta.label,
335
- kind: BOOLEAN_KEYS.has(key) ? 'boolean' : options !== undefined ? 'select' : meta.min !== undefined ? 'number' : 'text',
424
+ kind: list !== undefined
425
+ ? 'string-list'
426
+ : BOOLEAN_KEYS.has(key) ? 'boolean' : options !== undefined ? 'select' : meta.min !== undefined ? 'number' : 'text',
336
427
  group: GROUP_OF[key],
337
428
  help: meta.help,
338
429
  ...(options === undefined ? {} : { options }),
@@ -340,6 +431,8 @@ export const FIELD_DESCRIPTORS: readonly EditableFieldWire[] = EDITABLE_KEYS.map
340
431
  ...(meta.max === undefined ? {} : { max: meta.max }),
341
432
  ...(meta.step === undefined ? {} : { step: meta.step }),
342
433
  ...(meta.placeholder === undefined ? {} : { placeholder: meta.placeholder }),
434
+ ...(list === undefined ? {} : { maxItems: list.maxItems }),
435
+ ...(list?.itemFormat === undefined ? {} : { itemFormat: list.itemFormat }),
343
436
  ...(meta.wide === undefined ? {} : { wide: meta.wide }),
344
437
  restartRequired: RESTART_SET.has(key),
345
438
  }
@@ -726,6 +819,7 @@ export class SettingsSurface {
726
819
 
727
820
  const raw: Record<string, unknown> = {}
728
821
  const clearedKeys: EditableKey[] = []
822
+ const listIssues: FieldIssueWire[] = []
729
823
  for (const key of EDITABLE_KEYS) {
730
824
  const value = values[key]
731
825
  if (value === undefined) continue
@@ -733,15 +827,22 @@ export class SettingsSurface {
733
827
  clearedKeys.push(key)
734
828
  continue
735
829
  }
830
+ const list = STRING_LIST_FIELDS[key]
831
+ if (list !== undefined) {
832
+ const normalized = normalizeStringList(key, value, list)
833
+ if (normalized.ok) setOverlayValue(raw, key, normalized.value)
834
+ else listIssues.push(...normalized.issues)
835
+ continue
836
+ }
736
837
  setOverlayValue(raw, key, value)
737
838
  }
738
839
 
739
840
  const parsed = parseOverlay(raw)
740
- if (!parsed.ok) {
741
- const issues: FieldIssueWire[] = parsed.issues.map(issue => ({
742
- key: issue.path,
743
- message: issue.message,
744
- }))
841
+ if (!parsed.ok || listIssues.length > 0) {
842
+ const issues: FieldIssueWire[] = [
843
+ ...listIssues,
844
+ ...(parsed.ok ? [] : parsed.issues.map(issue => ({ key: issue.path, message: issue.message }))),
845
+ ]
745
846
  return { ok: false, error: '配置校验失败', issues }
746
847
  }
747
848