@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.
- package/README.md +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/feishu-onboarding.md +3 -0
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +2061 -212
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +32 -0
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +23 -0
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +10 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +107 -6
package/src/feishu-channel.ts
CHANGED
|
@@ -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.
|
|
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') {
|
package/src/settings/contract.ts
CHANGED
|
@@ -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
|
-
/**
|
|
114
|
-
|
|
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
|
-
/**
|
|
186
|
-
|
|
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: '已选择目录',
|
package/src/settings/surface.ts
CHANGED
|
@@ -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:
|
|
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[] =
|
|
742
|
-
|
|
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
|
|