@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.5
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 +88 -27
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +3 -3
- package/lib/client.js +1427 -1338
- package/lib/client.js.map +1 -1
- package/lib/index.js +7958 -6710
- 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/number-field.ts +97 -0
- package/src/client/panel/access-tab.tsx +61 -20
- package/src/client/panel/advanced-tab.tsx +59 -68
- package/src/client/panel/connection-tab.tsx +147 -164
- package/src/client/panel/credentials-card.tsx +124 -87
- package/src/client/panel/fields-tab.tsx +27 -0
- package/src/client/panel/index.tsx +126 -55
- package/src/client/panel/shared.tsx +369 -252
- package/src/client/panel/whitelist.tsx +197 -0
- package/src/client/panel/wiki-tab.tsx +48 -0
- package/src/client/styles.ts +108 -91
- 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 +2 -2
- 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 +222 -146
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +234 -203
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
- package/src/client/panel/access-card.tsx +0 -303
- package/src/client/panel/model-tab.tsx +0 -23
- package/src/client/panel/sessions-tab.tsx +0 -21
- package/src/client/panel/workspace-tab.tsx +0 -24
|
@@ -1,16 +1,106 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 凭据行:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示「已配置 /
|
|
3
|
+
* 未配置」,两个方向都不回显已存值。已配置时只显示「更换 / 移除」,点「更换」
|
|
4
|
+
* 才出现输入框。保存动作由页面外壳持有(凭据保存独立于配置 draft)。
|
|
5
5
|
*
|
|
6
6
|
* @module dsh-channels/client/panel/credentials-card
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
10
|
import { PANEL_COPY } from '../../settings/contract.ts'
|
|
10
|
-
import type {
|
|
11
|
-
import
|
|
11
|
+
import type { CredentialRefWire, FieldIssueWire, PanelWire } from '../../settings/contract.ts'
|
|
12
|
+
import { Pill, Row, Section } from './shared.tsx'
|
|
13
|
+
import type { BusyAction, CredentialsView } from './shared.tsx'
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/** One credential slot as a setting row with its own input and actions. */
|
|
16
|
+
export function CredentialRow(props: {
|
|
17
|
+
label: string
|
|
18
|
+
summary?: string
|
|
19
|
+
state: CredentialRefWire
|
|
20
|
+
value: string
|
|
21
|
+
secret: boolean
|
|
22
|
+
placeholder: string
|
|
23
|
+
issue?: FieldIssueWire
|
|
24
|
+
busy: BusyAction | null
|
|
25
|
+
disabled: boolean
|
|
26
|
+
onChange: (value: string) => void
|
|
27
|
+
onSave: () => void
|
|
28
|
+
onClear: () => void
|
|
29
|
+
}): JSX.Element {
|
|
30
|
+
const { label, summary, state, value, secret, placeholder, issue, busy, disabled, onChange, onSave, onClear } = props
|
|
31
|
+
const [editing, setEditing] = useState(false)
|
|
32
|
+
// Close the replace input once a credential save finishes (the shell clears the value).
|
|
33
|
+
const previousBusy = useRef(busy)
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (previousBusy.current === 'credentials' && busy === null) setEditing(false)
|
|
36
|
+
previousBusy.current = busy
|
|
37
|
+
}, [busy])
|
|
38
|
+
const showInput = !state.configured || editing || value !== ''
|
|
39
|
+
const source = `${PANEL_COPY.credentialStore} · ${state.ref}${state.source === undefined ? '' : ` · ${state.source}`}`
|
|
40
|
+
return (
|
|
41
|
+
<Row
|
|
42
|
+
label={label}
|
|
43
|
+
tags={(
|
|
44
|
+
<Pill tone={state.configured ? 'ok' : 'muted'}>
|
|
45
|
+
{state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
|
|
46
|
+
</Pill>
|
|
47
|
+
)}
|
|
48
|
+
summary={summary === undefined ? <span className="dsh-channels-cfg__mono">{source}</span> : summary}
|
|
49
|
+
error={issue?.message ?? (state.writable ? undefined : PANEL_COPY.credentialReadOnly)}
|
|
50
|
+
control={showInput
|
|
51
|
+
? (
|
|
52
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
53
|
+
<input
|
|
54
|
+
type={secret ? 'password' : 'text'}
|
|
55
|
+
className={`dsh-channels-cfg__input dsh-channels-cfg__input--text${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
|
|
56
|
+
value={value}
|
|
57
|
+
placeholder={placeholder}
|
|
58
|
+
autoComplete="new-password"
|
|
59
|
+
aria-label={label}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
onChange={event => onChange(event.target.value)}
|
|
62
|
+
/>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
|
|
66
|
+
disabled={disabled || value.trim() === ''}
|
|
67
|
+
onClick={onSave}
|
|
68
|
+
>
|
|
69
|
+
{busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
|
|
70
|
+
</button>
|
|
71
|
+
{state.configured
|
|
72
|
+
? (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
|
|
76
|
+
disabled={busy !== null}
|
|
77
|
+
onClick={() => {
|
|
78
|
+
setEditing(false)
|
|
79
|
+
onChange('')
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{PANEL_COPY.cancel}
|
|
83
|
+
</button>
|
|
84
|
+
)
|
|
85
|
+
: null}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
: (
|
|
89
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
90
|
+
<button type="button" className="dsh-channels-cfg__btn" disabled={disabled} onClick={() => setEditing(true)}>
|
|
91
|
+
{PANEL_COPY.replaceCredential}
|
|
92
|
+
</button>
|
|
93
|
+
<button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--remove" disabled={disabled} onClick={onClear}>
|
|
94
|
+
{busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Feishu application pair (App ID / App Secret). */
|
|
103
|
+
export function CredentialsSection(props: {
|
|
14
104
|
panel: PanelWire
|
|
15
105
|
credentials: CredentialsView
|
|
16
106
|
}): JSX.Element {
|
|
@@ -18,87 +108,34 @@ export function CredentialsCard(props: {
|
|
|
18
108
|
const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials
|
|
19
109
|
const appIdState = panel.config.credentials.appId
|
|
20
110
|
const appSecretState = panel.config.credentials.appSecret
|
|
21
|
-
const configured = appIdState.configured && appSecretState.configured
|
|
22
|
-
const writable = appIdState.writable || appSecretState.writable
|
|
23
|
-
const disabled = busy !== null || locked || !writable
|
|
24
|
-
const pending = appId.trim() !== '' || appSecret.trim() !== ''
|
|
25
|
-
|
|
26
|
-
const row = (
|
|
27
|
-
field: CredentialField,
|
|
28
|
-
label: string,
|
|
29
|
-
state: CredentialRefWire,
|
|
30
|
-
value: string,
|
|
31
|
-
type: 'text' | 'password',
|
|
32
|
-
placeholder: string,
|
|
33
|
-
onChange: (next: string) => void,
|
|
34
|
-
): JSX.Element => {
|
|
35
|
-
const issue = issues.find(item => item.key === field)
|
|
36
|
-
const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
|
|
37
|
-
return (
|
|
38
|
-
<div className="dsh-channels-cfg__field" key={field}>
|
|
39
|
-
<div className="dsh-channels-cfg__labelRow">
|
|
40
|
-
<span className="dsh-channels-cfg__label">{label}</span>
|
|
41
|
-
<span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'bad'}`}>
|
|
42
|
-
{state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
|
|
43
|
-
</span>
|
|
44
|
-
</div>
|
|
45
|
-
<div className="dsh-channels-cfg__cred">
|
|
46
|
-
<input
|
|
47
|
-
type={type}
|
|
48
|
-
className={inputClass}
|
|
49
|
-
value={value}
|
|
50
|
-
placeholder={placeholder}
|
|
51
|
-
autoComplete="new-password"
|
|
52
|
-
aria-label={label}
|
|
53
|
-
disabled={disabled}
|
|
54
|
-
onChange={event => onChange(event.target.value)}
|
|
55
|
-
/>
|
|
56
|
-
{state.configured
|
|
57
|
-
? (
|
|
58
|
-
<button
|
|
59
|
-
type="button"
|
|
60
|
-
className="dsh-channels-cfg__btn"
|
|
61
|
-
disabled={disabled}
|
|
62
|
-
onClick={() => onClear(field)}
|
|
63
|
-
>
|
|
64
|
-
{busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
|
|
65
|
-
</button>
|
|
66
|
-
)
|
|
67
|
-
: null}
|
|
68
|
-
</div>
|
|
69
|
-
<span className="dsh-channels-cfg__help">
|
|
70
|
-
{PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
|
|
71
|
-
{state.source === undefined ? '' : ` · ${state.source}`}
|
|
72
|
-
</span>
|
|
73
|
-
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
74
|
-
</div>
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
111
|
return (
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
<Section title={PANEL_COPY.credentialsTitle} footnote={PANEL_COPY.credentialsHint}>
|
|
113
|
+
<CredentialRow
|
|
114
|
+
label={PANEL_COPY.appIdLabel}
|
|
115
|
+
state={appIdState}
|
|
116
|
+
value={appId}
|
|
117
|
+
secret={false}
|
|
118
|
+
placeholder={PANEL_COPY.appIdPlaceholder}
|
|
119
|
+
issue={issues.find(item => item.key === 'appId')}
|
|
120
|
+
busy={busy}
|
|
121
|
+
disabled={busy !== null || locked || !appIdState.writable}
|
|
122
|
+
onChange={onAppIdChange}
|
|
123
|
+
onSave={onSave}
|
|
124
|
+
onClear={() => onClear('appId')}
|
|
125
|
+
/>
|
|
126
|
+
<CredentialRow
|
|
127
|
+
label={PANEL_COPY.appSecretLabel}
|
|
128
|
+
state={appSecretState}
|
|
129
|
+
value={appSecret}
|
|
130
|
+
secret
|
|
131
|
+
placeholder={PANEL_COPY.appSecretPlaceholder}
|
|
132
|
+
issue={issues.find(item => item.key === 'appSecret')}
|
|
133
|
+
busy={busy}
|
|
134
|
+
disabled={busy !== null || locked || !appSecretState.writable}
|
|
135
|
+
onChange={onAppSecretChange}
|
|
136
|
+
onSave={onSave}
|
|
137
|
+
onClear={() => onClear('appSecret')}
|
|
138
|
+
/>
|
|
139
|
+
</Section>
|
|
103
140
|
)
|
|
104
141
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs made only of Host fields (「会话」「模型」「附件与工具」): one titled
|
|
3
|
+
* section per field group the tab owns (`TAB_FIELD_GROUPS`).
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-channels/client/panel/fields-tab
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
10
|
+
import type { FieldGroup, PanelTabId } from '../../settings/contract.ts'
|
|
11
|
+
import { FieldSections } from './shared.tsx'
|
|
12
|
+
import type { FormView } from './shared.tsx'
|
|
13
|
+
|
|
14
|
+
/** Footnotes under one group's section, per group. */
|
|
15
|
+
const FOOTNOTES: Partial<Record<FieldGroup, ReactNode>> = {
|
|
16
|
+
model: PANEL_COPY.modelFootnote,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function FieldsTab(props: { form: FormView; tab: PanelTabId; note?: string }): JSX.Element {
|
|
20
|
+
const { form, tab, note } = props
|
|
21
|
+
return (
|
|
22
|
+
<div className="dsh-channels-cfg__tabBody">
|
|
23
|
+
{note === undefined ? null : <p className="dsh-channels-cfg__secFoot">{note}</p>}
|
|
24
|
+
<FieldSections form={form} groups={TAB_FIELD_GROUPS[tab]} footnotes={FOOTNOTES} />
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft
|
|
3
|
-
* tab
|
|
2
|
+
* 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
|
|
3
|
+
* tab 导航与底部保存条的外壳。
|
|
4
4
|
*
|
|
5
5
|
* 所有 Host 交互(panel / status / catalog / config / credentials / access /
|
|
6
6
|
* pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
|
|
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
14
14
|
import type { KeyboardEvent } from 'react'
|
|
15
15
|
import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
16
16
|
import type {
|
|
17
|
+
AccessMutationResponse,
|
|
17
18
|
AccessWire,
|
|
18
19
|
CatalogWire,
|
|
19
20
|
ChannelStatusWire,
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
import {
|
|
29
30
|
addAccessGroup,
|
|
30
31
|
addAccessUser,
|
|
32
|
+
addWikiUser,
|
|
31
33
|
clearConfig,
|
|
32
34
|
clearCredential,
|
|
33
35
|
fetchCatalog,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
reconnect,
|
|
38
40
|
removeAccessGroup,
|
|
39
41
|
removeAccessUser,
|
|
42
|
+
removeWikiUser,
|
|
40
43
|
resolveAccessRequest,
|
|
41
44
|
saveConfig,
|
|
42
45
|
saveCredentials,
|
|
@@ -45,24 +48,26 @@ import { applyCatalogFields, catalogFromFields } from '../catalog.ts'
|
|
|
45
48
|
import { AccessTab } from './access-tab.tsx'
|
|
46
49
|
import { AdvancedTab } from './advanced-tab.tsx'
|
|
47
50
|
import { ConnectionTab } from './connection-tab.tsx'
|
|
48
|
-
import {
|
|
49
|
-
import { SessionsTab } from './sessions-tab.tsx'
|
|
51
|
+
import { FieldsTab } from './fields-tab.tsx'
|
|
50
52
|
import {
|
|
51
53
|
fieldLabel,
|
|
54
|
+
formatUptime,
|
|
52
55
|
messageOf,
|
|
53
56
|
NoticeBar,
|
|
54
57
|
phaseBadge,
|
|
55
|
-
|
|
58
|
+
Pill,
|
|
59
|
+
SaveBar,
|
|
56
60
|
validateDraft,
|
|
57
61
|
} from './shared.tsx'
|
|
58
62
|
import type {
|
|
63
|
+
AnalyzeTokenView,
|
|
59
64
|
BusyAction,
|
|
60
65
|
CredentialsView,
|
|
61
66
|
FormView,
|
|
62
67
|
LoadState,
|
|
63
68
|
Notice,
|
|
64
69
|
} from './shared.tsx'
|
|
65
|
-
import {
|
|
70
|
+
import { WikiTab } from './wiki-tab.tsx'
|
|
66
71
|
|
|
67
72
|
/** Owner props handed to a `settings.section` contribution (unused beyond parity). */
|
|
68
73
|
export interface FeishuChannelsSectionProps {
|
|
@@ -80,6 +85,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
80
85
|
const [pollError, setPollError] = useState<string | null>(null)
|
|
81
86
|
const [appIdInput, setAppIdInput] = useState('')
|
|
82
87
|
const [appSecretInput, setAppSecretInput] = useState('')
|
|
88
|
+
const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
|
|
83
89
|
const [catalog, setCatalog] = useState<CatalogWire | null>(null)
|
|
84
90
|
const [catalogBusy, setCatalogBusy] = useState(false)
|
|
85
91
|
const [accessNotice, setAccessNotice] = useState<string | null>(null)
|
|
@@ -106,6 +112,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
106
112
|
setDraft({})
|
|
107
113
|
setAppIdInput('')
|
|
108
114
|
setAppSecretInput('')
|
|
115
|
+
setAnalyzeTokenInput('')
|
|
109
116
|
} catch (error) {
|
|
110
117
|
setLoad({ phase: 'error', message: messageOf(error) })
|
|
111
118
|
}
|
|
@@ -309,24 +316,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
309
316
|
}
|
|
310
317
|
}, [draft, panel])
|
|
311
318
|
|
|
312
|
-
/** Drop the draft
|
|
313
|
-
const
|
|
319
|
+
/** Drop the whole draft (nothing is written). */
|
|
320
|
+
const discardDraft = useCallback((): void => {
|
|
314
321
|
if (panel === null) return
|
|
315
|
-
|
|
316
|
-
const keys = new Set(
|
|
317
|
-
panel.config.fields.filter(field => groups.has(field.group)).map(field => field.key),
|
|
318
|
-
)
|
|
319
|
-
setDraft((previous) => {
|
|
320
|
-
const next = { ...previous }
|
|
321
|
-
for (const key of keys) delete next[key]
|
|
322
|
-
return next
|
|
323
|
-
})
|
|
322
|
+
setDraft({})
|
|
324
323
|
setNotice(null)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
setCatalogBusy(false)
|
|
329
|
-
}
|
|
324
|
+
catalogTicket.current += 1
|
|
325
|
+
setCatalog(catalogFromFields(panel.config.fields))
|
|
326
|
+
setCatalogBusy(false)
|
|
330
327
|
}, [panel])
|
|
331
328
|
|
|
332
329
|
/**
|
|
@@ -352,6 +349,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
352
349
|
|
|
353
350
|
/** Drop every Web override and return the refreshed panel. */
|
|
354
351
|
const onClear = useCallback(async (): Promise<void> => {
|
|
352
|
+
const confirmed = typeof window.confirm === 'function'
|
|
353
|
+
? window.confirm(PANEL_COPY.clearConfirm)
|
|
354
|
+
: true
|
|
355
|
+
if (!confirmed) return
|
|
355
356
|
setBusy('clear')
|
|
356
357
|
setNotice(null)
|
|
357
358
|
try {
|
|
@@ -453,18 +454,24 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
453
454
|
}
|
|
454
455
|
}, [applyAccess])
|
|
455
456
|
|
|
456
|
-
|
|
457
|
+
/** Add one user to a whitelist; resolves true when the input should clear. */
|
|
458
|
+
const addUser = useCallback(async (
|
|
459
|
+
add: (userId: string, name: string) => Promise<AccessMutationResponse>,
|
|
460
|
+
userId: string,
|
|
461
|
+
name: string,
|
|
462
|
+
notice: string,
|
|
463
|
+
): Promise<boolean> => {
|
|
457
464
|
setBusy('access')
|
|
458
465
|
setAccessError(null)
|
|
459
466
|
setAccessNotice(null)
|
|
460
467
|
try {
|
|
461
|
-
const result = await
|
|
468
|
+
const result = await add(userId, name)
|
|
462
469
|
if (!result.ok) {
|
|
463
470
|
setAccessError(result.error)
|
|
464
471
|
return false
|
|
465
472
|
}
|
|
466
473
|
applyAccess(result.access)
|
|
467
|
-
setAccessNotice(
|
|
474
|
+
setAccessNotice(notice)
|
|
468
475
|
return true
|
|
469
476
|
} catch (error) {
|
|
470
477
|
setAccessError(messageOf(error))
|
|
@@ -474,18 +481,22 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
474
481
|
}
|
|
475
482
|
}, [applyAccess])
|
|
476
483
|
|
|
477
|
-
const
|
|
484
|
+
const removeUser = useCallback(async (
|
|
485
|
+
remove: (userId: string) => Promise<AccessMutationResponse>,
|
|
486
|
+
userId: string,
|
|
487
|
+
notice: string,
|
|
488
|
+
): Promise<void> => {
|
|
478
489
|
setBusy('access')
|
|
479
490
|
setAccessError(null)
|
|
480
491
|
setAccessNotice(null)
|
|
481
492
|
try {
|
|
482
|
-
const result = await
|
|
493
|
+
const result = await remove(userId)
|
|
483
494
|
if (!result.ok) {
|
|
484
495
|
setAccessError(result.error)
|
|
485
496
|
return
|
|
486
497
|
}
|
|
487
498
|
applyAccess(result.access)
|
|
488
|
-
setAccessNotice(
|
|
499
|
+
setAccessNotice(notice)
|
|
489
500
|
} catch (error) {
|
|
490
501
|
setAccessError(messageOf(error))
|
|
491
502
|
} finally {
|
|
@@ -552,6 +563,28 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
552
563
|
}
|
|
553
564
|
}, [appIdInput, appSecretInput])
|
|
554
565
|
|
|
566
|
+
/** Save the analyze API token; it applies to the next request, no reconnect. */
|
|
567
|
+
const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
|
|
568
|
+
if (analyzeTokenInput.trim() === '') return
|
|
569
|
+
setBusy('credentials')
|
|
570
|
+
setNotice(null)
|
|
571
|
+
try {
|
|
572
|
+
const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
|
|
573
|
+
if (!result.ok) {
|
|
574
|
+
setNotice({ kind: 'error', text: result.error, issues: result.issues })
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
578
|
+
setStatus(result.panel.status)
|
|
579
|
+
setAnalyzeTokenInput('')
|
|
580
|
+
setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
|
|
581
|
+
} catch (error) {
|
|
582
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
583
|
+
} finally {
|
|
584
|
+
setBusy(null)
|
|
585
|
+
}
|
|
586
|
+
}, [analyzeTokenInput])
|
|
587
|
+
|
|
555
588
|
const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
|
|
556
589
|
const confirmed = typeof window.confirm === 'function'
|
|
557
590
|
? window.confirm(PANEL_COPY.credentialRemoveConfirm)
|
|
@@ -569,7 +602,9 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
569
602
|
setStatus(result.panel.status)
|
|
570
603
|
setNotice({
|
|
571
604
|
kind: 'ok',
|
|
572
|
-
text:
|
|
605
|
+
text: field === 'analyzeToken'
|
|
606
|
+
? PANEL_COPY.analyzeTokenRemoved
|
|
607
|
+
: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
|
|
573
608
|
})
|
|
574
609
|
} catch (error) {
|
|
575
610
|
setNotice({ kind: 'error', text: messageOf(error) })
|
|
@@ -583,6 +618,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
583
618
|
[notice],
|
|
584
619
|
)
|
|
585
620
|
const dirtyCount = Object.keys(draft).length
|
|
621
|
+
const restartCount = useMemo(
|
|
622
|
+
() => (panel?.config.fields ?? []).filter(field => field.restartRequired && field.key in draft).length,
|
|
623
|
+
[draft, panel],
|
|
624
|
+
)
|
|
586
625
|
|
|
587
626
|
/** Which tabs currently own a dirty key (the dot is per tab, not global). */
|
|
588
627
|
const dirtyTabs = useMemo<ReadonlySet<PanelTabId>>(() => {
|
|
@@ -608,13 +647,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
608
647
|
issues,
|
|
609
648
|
busy,
|
|
610
649
|
locked: hostBusy,
|
|
611
|
-
dirtyCount,
|
|
612
650
|
onChange: updateDraft,
|
|
613
|
-
onSave: () => { void onSave() },
|
|
614
|
-
onDiscardTab: discardTab,
|
|
615
651
|
onPickDirectory,
|
|
616
652
|
}
|
|
617
|
-
}, [busy,
|
|
653
|
+
}, [busy, draft, hostBusy, issues, onPickDirectory, panel, renderedFields, updateDraft])
|
|
618
654
|
|
|
619
655
|
const credentials = useMemo<CredentialsView>(() => ({
|
|
620
656
|
appId: appIdInput,
|
|
@@ -628,6 +664,16 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
628
664
|
onClear: (field: CredentialField) => { void onClearCredential(field) },
|
|
629
665
|
}), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
|
|
630
666
|
|
|
667
|
+
const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
|
|
668
|
+
value: analyzeTokenInput,
|
|
669
|
+
issues,
|
|
670
|
+
busy,
|
|
671
|
+
locked: hostBusy,
|
|
672
|
+
onChange: setAnalyzeTokenInput,
|
|
673
|
+
onSave: () => { void onSaveAnalyzeToken() },
|
|
674
|
+
onClear: () => { void onClearCredential('analyzeToken') },
|
|
675
|
+
}), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
|
|
676
|
+
|
|
631
677
|
if (load.phase === 'loading') {
|
|
632
678
|
return (
|
|
633
679
|
<div className="dsh-channels-cfg">
|
|
@@ -662,8 +708,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
662
708
|
locked={hostBusy}
|
|
663
709
|
onRefresh={() => { void onRefresh() }}
|
|
664
710
|
/>
|
|
665
|
-
<TabNav
|
|
666
|
-
|
|
711
|
+
<TabNav
|
|
712
|
+
activeTab={activeTab}
|
|
713
|
+
dirtyTabs={dirtyTabs}
|
|
714
|
+
counts={{ access: load.panel.access.loaded ? load.panel.access.requests.length : 0 }}
|
|
715
|
+
onChange={setActiveTab}
|
|
716
|
+
/>
|
|
717
|
+
{load.panel.access.mode === 'open' ? <div className="dsh-channels-cfg__banner">{PANEL_COPY.debugModeWarning}</div> : null}
|
|
718
|
+
{notice === null ? null : <NoticeBar kind={notice.kind} text={notice.text} />}
|
|
667
719
|
<div
|
|
668
720
|
role="tabpanel"
|
|
669
721
|
id={`dsh-channels-tabpanel-${activeTab}`}
|
|
@@ -680,13 +732,11 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
680
732
|
/>
|
|
681
733
|
)
|
|
682
734
|
: null}
|
|
683
|
-
{activeTab === 'sessions' ? <
|
|
684
|
-
{activeTab === 'model'
|
|
685
|
-
|
|
686
|
-
? (
|
|
687
|
-
<WorkspaceTab form={form} />
|
|
688
|
-
)
|
|
735
|
+
{activeTab === 'sessions' ? <FieldsTab form={form} tab="sessions" /> : null}
|
|
736
|
+
{activeTab === 'model'
|
|
737
|
+
? <FieldsTab form={form} tab="model" note={catalogBusy ? PANEL_COPY.catalogLoading : undefined} />
|
|
689
738
|
: null}
|
|
739
|
+
{activeTab === 'attachments' ? <FieldsTab form={form} tab="attachments" /> : null}
|
|
690
740
|
{activeTab === 'access'
|
|
691
741
|
? (
|
|
692
742
|
<AccessTab
|
|
@@ -695,21 +745,40 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
695
745
|
accessError={accessError}
|
|
696
746
|
onAddGroup={onAddAccessGroup}
|
|
697
747
|
onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
|
|
698
|
-
onAddUser={
|
|
699
|
-
onRemoveUser={userId => { void
|
|
748
|
+
onAddUser={(userId, name) => addUser(addAccessUser, userId, name, PANEL_COPY.accessUserAdded)}
|
|
749
|
+
onRemoveUser={userId => { void removeUser(removeAccessUser, userId, PANEL_COPY.accessUserRemoved) }}
|
|
700
750
|
onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
|
|
701
751
|
/>
|
|
702
752
|
)
|
|
703
753
|
: null}
|
|
754
|
+
{activeTab === 'wiki'
|
|
755
|
+
? (
|
|
756
|
+
<WikiTab
|
|
757
|
+
form={form}
|
|
758
|
+
accessNotice={accessNotice}
|
|
759
|
+
accessError={accessError}
|
|
760
|
+
onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
|
|
761
|
+
onRemoveUser={userId => { void removeUser(removeWikiUser, userId, PANEL_COPY.wikiUserRemoved) }}
|
|
762
|
+
/>
|
|
763
|
+
)
|
|
764
|
+
: null}
|
|
704
765
|
{activeTab === 'advanced'
|
|
705
|
-
? <AdvancedTab form={form} onClear={() => { void onClear() }} />
|
|
766
|
+
? <AdvancedTab form={form} analyzeToken={analyzeToken} onClear={() => { void onClear() }} />
|
|
706
767
|
: null}
|
|
707
768
|
</div>
|
|
769
|
+
<SaveBar
|
|
770
|
+
dirtyCount={dirtyCount}
|
|
771
|
+
restartCount={restartCount}
|
|
772
|
+
busy={busy}
|
|
773
|
+
locked={hostBusy}
|
|
774
|
+
onSave={() => { void onSave() }}
|
|
775
|
+
onDiscard={discardDraft}
|
|
776
|
+
/>
|
|
708
777
|
</div>
|
|
709
778
|
)
|
|
710
779
|
}
|
|
711
780
|
|
|
712
|
-
/** Page header: title
|
|
781
|
+
/** Page header: title, phase pill, version and uptime, refresh; visible on every tab. */
|
|
713
782
|
function PanelHeader(props: {
|
|
714
783
|
status: ChannelStatusWire
|
|
715
784
|
pollError: string | null
|
|
@@ -720,24 +789,24 @@ function PanelHeader(props: {
|
|
|
720
789
|
const { status, pollError, busy, locked, onRefresh } = props
|
|
721
790
|
const badge = phaseBadge(status.phase)
|
|
722
791
|
return (
|
|
723
|
-
<header className="dsh-channels-cfg__panelHead"
|
|
792
|
+
<header className="dsh-channels-cfg__panelHead">
|
|
724
793
|
<div className="dsh-channels-cfg__headRow">
|
|
725
794
|
<h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
|
|
726
|
-
<
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
795
|
+
<Pill tone={badge.tone} dot>{badge.text}</Pill>
|
|
796
|
+
<span className="dsh-channels-cfg__meta dsh-channels-cfg__mono">v{status.version}</span>
|
|
797
|
+
{status.uptimeMs === undefined
|
|
798
|
+
? null
|
|
799
|
+
: <span className="dsh-channels-cfg__meta">· {PANEL_COPY.uptimePrefix} {formatUptime(status.uptimeMs)}</span>}
|
|
730
800
|
<span className="dsh-channels-cfg__spacer" />
|
|
731
801
|
<button
|
|
732
802
|
type="button"
|
|
733
|
-
className="dsh-channels-cfg__btn"
|
|
803
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
|
|
734
804
|
onClick={onRefresh}
|
|
735
805
|
disabled={busy !== null || locked}
|
|
736
806
|
>
|
|
737
807
|
{PANEL_COPY.refresh}
|
|
738
808
|
</button>
|
|
739
809
|
</div>
|
|
740
|
-
<p className="dsh-channels-cfg__summary">{phaseSummary(status.phase)}</p>
|
|
741
810
|
{pollError === null
|
|
742
811
|
? null
|
|
743
812
|
: (
|
|
@@ -753,9 +822,11 @@ function PanelHeader(props: {
|
|
|
753
822
|
function TabNav(props: {
|
|
754
823
|
activeTab: PanelTabId
|
|
755
824
|
dirtyTabs: ReadonlySet<PanelTabId>
|
|
825
|
+
/** Items waiting for the operator per tab (pending access requests). */
|
|
826
|
+
counts: Partial<Record<PanelTabId, number>>
|
|
756
827
|
onChange: (tab: PanelTabId) => void
|
|
757
828
|
}): JSX.Element {
|
|
758
|
-
const { activeTab, dirtyTabs, onChange } = props
|
|
829
|
+
const { activeTab, dirtyTabs, counts, onChange } = props
|
|
759
830
|
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
760
831
|
|
|
761
832
|
/** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
|
|
@@ -794,12 +865,12 @@ function TabNav(props: {
|
|
|
794
865
|
onKeyDown={event => onKeyDown(index, event)}
|
|
795
866
|
>
|
|
796
867
|
{tab.label}
|
|
868
|
+
{(counts[tab.id] ?? 0) > 0 ? <span className="dsh-channels-cfg__tabCount">{counts[tab.id]}</span> : null}
|
|
797
869
|
{dirty
|
|
798
870
|
? (
|
|
799
871
|
<span
|
|
800
872
|
className="dsh-channels-cfg__tabDot"
|
|
801
|
-
|
|
802
|
-
aria-label={PANEL_COPY.dirtyCount}
|
|
873
|
+
aria-hidden="true"
|
|
803
874
|
/>
|
|
804
875
|
)
|
|
805
876
|
: null}
|