@modesto_ma/dsh-channels 0.5.0-beta.4 → 0.5.0-beta.6
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 +49 -28
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +6 -5
- package/lib/client.js +1874 -1510
- package/lib/client.js.map +1 -1
- package/lib/index.js +736 -353
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/api.ts +45 -2
- package/src/client/avatar.tsx +36 -0
- package/src/client/icon.tsx +25 -0
- package/src/client/index.ts +78 -23
- package/src/client/number-field.ts +97 -0
- package/src/client/origin-cache.ts +45 -0
- package/src/client/origin.tsx +201 -0
- package/src/client/panel/access-tab.tsx +90 -21
- 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 +77 -54
- package/src/client/panel/profiles.ts +73 -0
- package/src/client/panel/shared.tsx +358 -253
- package/src/client/panel/whitelist.tsx +203 -0
- package/src/client/panel/wiki-tab.tsx +33 -39
- package/src/client/styles.ts +132 -92
- package/src/domain.ts +42 -0
- package/src/feishu/scopes.ts +2 -1
- package/src/feishu-channel.ts +87 -0
- package/src/index.ts +1 -1
- package/src/routing/inbound.ts +13 -5
- package/src/service.ts +146 -3
- package/src/session/routes.ts +45 -4
- package/src/settings/contract.ts +265 -152
- package/src/settings/routes.ts +47 -0
- package/src/settings/surface.ts +201 -197
- package/src/client/panel/access-card.tsx +0 -329
- package/src/client/panel/analyze-token-card.tsx +0 -70
- package/src/client/panel/model-tab.tsx +0 -23
- package/src/client/panel/sessions-tab.tsx +0 -24
- package/src/client/panel/workspace-tab.tsx +0 -24
|
@@ -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,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* tab
|
|
2
|
+
* 左侧栏「飞书渠道」主区页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
|
|
3
|
+
* tab 导航与底部保存条的外壳。
|
|
4
4
|
*
|
|
5
5
|
* 所有 Host 交互(panel / status / catalog / config / credentials / access /
|
|
6
6
|
* pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
|
|
7
|
-
* 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `
|
|
8
|
-
* 槽位。
|
|
7
|
+
* 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `main` 槽位。
|
|
9
8
|
*
|
|
10
9
|
* @module dsh-channels/client/panel
|
|
11
10
|
*/
|
|
@@ -48,14 +47,16 @@ import { applyCatalogFields, catalogFromFields } from '../catalog.ts'
|
|
|
48
47
|
import { AccessTab } from './access-tab.tsx'
|
|
49
48
|
import { AdvancedTab } from './advanced-tab.tsx'
|
|
50
49
|
import { ConnectionTab } from './connection-tab.tsx'
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
50
|
+
import { FieldsTab } from './fields-tab.tsx'
|
|
51
|
+
import { useFeishuProfiles } from './profiles.ts'
|
|
53
52
|
import {
|
|
54
53
|
fieldLabel,
|
|
54
|
+
formatUptime,
|
|
55
55
|
messageOf,
|
|
56
56
|
NoticeBar,
|
|
57
57
|
phaseBadge,
|
|
58
|
-
|
|
58
|
+
Pill,
|
|
59
|
+
SaveBar,
|
|
59
60
|
validateDraft,
|
|
60
61
|
} from './shared.tsx'
|
|
61
62
|
import type {
|
|
@@ -67,15 +68,24 @@ import type {
|
|
|
67
68
|
Notice,
|
|
68
69
|
} from './shared.tsx'
|
|
69
70
|
import { WikiTab } from './wiki-tab.tsx'
|
|
70
|
-
import { WorkspaceTab } from './workspace-tab.tsx'
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Main-column page for the `main` slot: a full-height scroll region with the
|
|
74
|
+
* centered panel column. The slot passes no owner props.
|
|
75
|
+
* @returns the page.
|
|
76
|
+
*/
|
|
77
|
+
export function FeishuChannelsPage(): JSX.Element {
|
|
78
|
+
return (
|
|
79
|
+
<section className="dsh-channels-page" aria-label={PANEL_COPY.title}>
|
|
80
|
+
<div className="dsh-channels-page__content">
|
|
81
|
+
<FeishuChannelsPanel />
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
)
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
87
|
+
/** Panel shell: one panel payload, one status poll, one global config draft. */
|
|
88
|
+
function FeishuChannelsPanel(): JSX.Element | null {
|
|
79
89
|
const [load, setLoad] = useState<LoadState>({ phase: 'loading' })
|
|
80
90
|
const [activeTab, setActiveTab] = useState<PanelTabId>('connection')
|
|
81
91
|
const [draft, setDraft] = useState<EditableValues>({})
|
|
@@ -316,24 +326,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
316
326
|
}
|
|
317
327
|
}, [draft, panel])
|
|
318
328
|
|
|
319
|
-
/** Drop the draft
|
|
320
|
-
const
|
|
329
|
+
/** Drop the whole draft (nothing is written). */
|
|
330
|
+
const discardDraft = useCallback((): void => {
|
|
321
331
|
if (panel === null) return
|
|
322
|
-
|
|
323
|
-
const keys = new Set(
|
|
324
|
-
panel.config.fields.filter(field => groups.has(field.group)).map(field => field.key),
|
|
325
|
-
)
|
|
326
|
-
setDraft((previous) => {
|
|
327
|
-
const next = { ...previous }
|
|
328
|
-
for (const key of keys) delete next[key]
|
|
329
|
-
return next
|
|
330
|
-
})
|
|
332
|
+
setDraft({})
|
|
331
333
|
setNotice(null)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
setCatalogBusy(false)
|
|
336
|
-
}
|
|
334
|
+
catalogTicket.current += 1
|
|
335
|
+
setCatalog(catalogFromFields(panel.config.fields))
|
|
336
|
+
setCatalogBusy(false)
|
|
337
337
|
}, [panel])
|
|
338
338
|
|
|
339
339
|
/**
|
|
@@ -359,6 +359,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
359
359
|
|
|
360
360
|
/** Drop every Web override and return the refreshed panel. */
|
|
361
361
|
const onClear = useCallback(async (): Promise<void> => {
|
|
362
|
+
const confirmed = typeof window.confirm === 'function'
|
|
363
|
+
? window.confirm(PANEL_COPY.clearConfirm)
|
|
364
|
+
: true
|
|
365
|
+
if (!confirmed) return
|
|
362
366
|
setBusy('clear')
|
|
363
367
|
setNotice(null)
|
|
364
368
|
try {
|
|
@@ -624,6 +628,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
624
628
|
[notice],
|
|
625
629
|
)
|
|
626
630
|
const dirtyCount = Object.keys(draft).length
|
|
631
|
+
const restartCount = useMemo(
|
|
632
|
+
() => (panel?.config.fields ?? []).filter(field => field.restartRequired && field.key in draft).length,
|
|
633
|
+
[draft, panel],
|
|
634
|
+
)
|
|
627
635
|
|
|
628
636
|
/** Which tabs currently own a dirty key (the dot is per tab, not global). */
|
|
629
637
|
const dirtyTabs = useMemo<ReadonlySet<PanelTabId>>(() => {
|
|
@@ -649,13 +657,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
649
657
|
issues,
|
|
650
658
|
busy,
|
|
651
659
|
locked: hostBusy,
|
|
652
|
-
dirtyCount,
|
|
653
660
|
onChange: updateDraft,
|
|
654
|
-
onSave: () => { void onSave() },
|
|
655
|
-
onDiscardTab: discardTab,
|
|
656
661
|
onPickDirectory,
|
|
657
662
|
}
|
|
658
|
-
}, [busy,
|
|
663
|
+
}, [busy, draft, hostBusy, issues, onPickDirectory, panel, renderedFields, updateDraft])
|
|
664
|
+
|
|
665
|
+
const profiles = useFeishuProfiles(panel?.access ?? null)
|
|
659
666
|
|
|
660
667
|
const credentials = useMemo<CredentialsView>(() => ({
|
|
661
668
|
appId: appIdInput,
|
|
@@ -713,8 +720,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
713
720
|
locked={hostBusy}
|
|
714
721
|
onRefresh={() => { void onRefresh() }}
|
|
715
722
|
/>
|
|
716
|
-
<TabNav
|
|
717
|
-
|
|
723
|
+
<TabNav
|
|
724
|
+
activeTab={activeTab}
|
|
725
|
+
dirtyTabs={dirtyTabs}
|
|
726
|
+
counts={{ access: load.panel.access.loaded ? load.panel.access.requests.length : 0 }}
|
|
727
|
+
onChange={setActiveTab}
|
|
728
|
+
/>
|
|
729
|
+
{load.panel.access.mode === 'open' ? <div className="dsh-channels-cfg__banner">{PANEL_COPY.debugModeWarning}</div> : null}
|
|
730
|
+
{notice === null ? null : <NoticeBar kind={notice.kind} text={notice.text} />}
|
|
718
731
|
<div
|
|
719
732
|
role="tabpanel"
|
|
720
733
|
id={`dsh-channels-tabpanel-${activeTab}`}
|
|
@@ -731,17 +744,16 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
731
744
|
/>
|
|
732
745
|
)
|
|
733
746
|
: null}
|
|
734
|
-
{activeTab === 'sessions' ? <
|
|
735
|
-
{activeTab === 'model'
|
|
736
|
-
|
|
737
|
-
? (
|
|
738
|
-
<WorkspaceTab form={form} />
|
|
739
|
-
)
|
|
747
|
+
{activeTab === 'sessions' ? <FieldsTab form={form} tab="sessions" /> : null}
|
|
748
|
+
{activeTab === 'model'
|
|
749
|
+
? <FieldsTab form={form} tab="model" note={catalogBusy ? PANEL_COPY.catalogLoading : undefined} />
|
|
740
750
|
: null}
|
|
751
|
+
{activeTab === 'attachments' ? <FieldsTab form={form} tab="attachments" /> : null}
|
|
741
752
|
{activeTab === 'access'
|
|
742
753
|
? (
|
|
743
754
|
<AccessTab
|
|
744
755
|
form={form}
|
|
756
|
+
profiles={profiles}
|
|
745
757
|
accessNotice={accessNotice}
|
|
746
758
|
accessError={accessError}
|
|
747
759
|
onAddGroup={onAddAccessGroup}
|
|
@@ -756,6 +768,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
756
768
|
? (
|
|
757
769
|
<WikiTab
|
|
758
770
|
form={form}
|
|
771
|
+
profiles={profiles}
|
|
759
772
|
accessNotice={accessNotice}
|
|
760
773
|
accessError={accessError}
|
|
761
774
|
onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
|
|
@@ -764,14 +777,22 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
764
777
|
)
|
|
765
778
|
: null}
|
|
766
779
|
{activeTab === 'advanced'
|
|
767
|
-
? <AdvancedTab form={form} onClear={() => { void onClear() }} />
|
|
780
|
+
? <AdvancedTab form={form} analyzeToken={analyzeToken} onClear={() => { void onClear() }} />
|
|
768
781
|
: null}
|
|
769
782
|
</div>
|
|
783
|
+
<SaveBar
|
|
784
|
+
dirtyCount={dirtyCount}
|
|
785
|
+
restartCount={restartCount}
|
|
786
|
+
busy={busy}
|
|
787
|
+
locked={hostBusy}
|
|
788
|
+
onSave={() => { void onSave() }}
|
|
789
|
+
onDiscard={discardDraft}
|
|
790
|
+
/>
|
|
770
791
|
</div>
|
|
771
792
|
)
|
|
772
793
|
}
|
|
773
794
|
|
|
774
|
-
/** Page header: title
|
|
795
|
+
/** Page header: title, phase pill, version and uptime, refresh; visible on every tab. */
|
|
775
796
|
function PanelHeader(props: {
|
|
776
797
|
status: ChannelStatusWire
|
|
777
798
|
pollError: string | null
|
|
@@ -782,24 +803,24 @@ function PanelHeader(props: {
|
|
|
782
803
|
const { status, pollError, busy, locked, onRefresh } = props
|
|
783
804
|
const badge = phaseBadge(status.phase)
|
|
784
805
|
return (
|
|
785
|
-
<header className="dsh-channels-cfg__panelHead"
|
|
806
|
+
<header className="dsh-channels-cfg__panelHead">
|
|
786
807
|
<div className="dsh-channels-cfg__headRow">
|
|
787
|
-
<
|
|
788
|
-
<
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
808
|
+
<h1 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h1>
|
|
809
|
+
<Pill tone={badge.tone} dot>{badge.text}</Pill>
|
|
810
|
+
<span className="dsh-channels-cfg__meta dsh-channels-cfg__mono">v{status.version}</span>
|
|
811
|
+
{status.uptimeMs === undefined
|
|
812
|
+
? null
|
|
813
|
+
: <span className="dsh-channels-cfg__meta">· {PANEL_COPY.uptimePrefix} {formatUptime(status.uptimeMs)}</span>}
|
|
792
814
|
<span className="dsh-channels-cfg__spacer" />
|
|
793
815
|
<button
|
|
794
816
|
type="button"
|
|
795
|
-
className="dsh-channels-cfg__btn"
|
|
817
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
|
|
796
818
|
onClick={onRefresh}
|
|
797
819
|
disabled={busy !== null || locked}
|
|
798
820
|
>
|
|
799
821
|
{PANEL_COPY.refresh}
|
|
800
822
|
</button>
|
|
801
823
|
</div>
|
|
802
|
-
<p className="dsh-channels-cfg__summary">{phaseSummary(status.phase)}</p>
|
|
803
824
|
{pollError === null
|
|
804
825
|
? null
|
|
805
826
|
: (
|
|
@@ -815,9 +836,11 @@ function PanelHeader(props: {
|
|
|
815
836
|
function TabNav(props: {
|
|
816
837
|
activeTab: PanelTabId
|
|
817
838
|
dirtyTabs: ReadonlySet<PanelTabId>
|
|
839
|
+
/** Items waiting for the operator per tab (pending access requests). */
|
|
840
|
+
counts: Partial<Record<PanelTabId, number>>
|
|
818
841
|
onChange: (tab: PanelTabId) => void
|
|
819
842
|
}): JSX.Element {
|
|
820
|
-
const { activeTab, dirtyTabs, onChange } = props
|
|
843
|
+
const { activeTab, dirtyTabs, counts, onChange } = props
|
|
821
844
|
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
822
845
|
|
|
823
846
|
/** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
|
|
@@ -856,12 +879,12 @@ function TabNav(props: {
|
|
|
856
879
|
onKeyDown={event => onKeyDown(index, event)}
|
|
857
880
|
>
|
|
858
881
|
{tab.label}
|
|
882
|
+
{(counts[tab.id] ?? 0) > 0 ? <span className="dsh-channels-cfg__tabCount">{counts[tab.id]}</span> : null}
|
|
859
883
|
{dirty
|
|
860
884
|
? (
|
|
861
885
|
<span
|
|
862
886
|
className="dsh-channels-cfg__tabDot"
|
|
863
|
-
|
|
864
|
-
aria-label={PANEL_COPY.dirtyCount}
|
|
887
|
+
aria-hidden="true"
|
|
865
888
|
/>
|
|
866
889
|
)
|
|
867
890
|
: null}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Feishu names and avatars for every user and chat the access and wiki
|
|
3
|
+
* tabs show. The page asks the Host once on entry and again whenever the set
|
|
4
|
+
* of shown ids changes; a failed request keeps the previous answer.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/client/panel/profiles
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
10
|
+
import type { AccessWire, FeishuProfilesWire, FeishuProfileWire } from '../../settings/contract.ts'
|
|
11
|
+
import { fetchFeishuProfiles } from '../api.ts'
|
|
12
|
+
|
|
13
|
+
/** Resolved profiles plus the reason some could not be read. */
|
|
14
|
+
export interface ProfileView {
|
|
15
|
+
user: (userId: string) => FeishuProfileWire | undefined
|
|
16
|
+
chat: (chatId: string) => FeishuProfileWire | undefined
|
|
17
|
+
/** Feishu or request failure reason, when one lookup failed. */
|
|
18
|
+
warning?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `user_id`s and `chat_id`s one access snapshot shows.
|
|
23
|
+
* @param access - the panel's access snapshot.
|
|
24
|
+
* @returns sorted, distinct ids per kind.
|
|
25
|
+
*/
|
|
26
|
+
export function profileIds(access: AccessWire): { users: string[]; chats: string[] } {
|
|
27
|
+
const users = new Set<string>()
|
|
28
|
+
const chats = new Set<string>()
|
|
29
|
+
if (access.admin.userId !== undefined) users.add(access.admin.userId)
|
|
30
|
+
for (const user of access.users) users.add(user.userId)
|
|
31
|
+
for (const user of access.wikiUsers) users.add(user.userId)
|
|
32
|
+
for (const group of access.groups) chats.add(group.chatId)
|
|
33
|
+
for (const request of access.requests) {
|
|
34
|
+
if (request.kind === 'user') users.add(request.targetId)
|
|
35
|
+
else chats.add(request.targetId)
|
|
36
|
+
if (request.chatId !== undefined) chats.add(request.chatId)
|
|
37
|
+
}
|
|
38
|
+
return { users: [...users].sort(), chats: [...chats].sort() }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Profiles for one access snapshot; empty until the panel has loaded.
|
|
43
|
+
* @param access - the panel's access snapshot, null while loading.
|
|
44
|
+
* @returns lookups by id and the latest failure reason.
|
|
45
|
+
*/
|
|
46
|
+
export function useFeishuProfiles(access: AccessWire | null): ProfileView {
|
|
47
|
+
const ids = useMemo(() => (access === null ? { users: [], chats: [] } : profileIds(access)), [access])
|
|
48
|
+
const key = `${ids.users.join(',')}|${ids.chats.join(',')}`
|
|
49
|
+
const [answer, setAnswer] = useState<FeishuProfilesWire>({ users: {}, chats: {} })
|
|
50
|
+
const [requestError, setRequestError] = useState<string | undefined>(undefined)
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (ids.users.length === 0 && ids.chats.length === 0) return
|
|
53
|
+
let current = true
|
|
54
|
+
fetchFeishuProfiles(ids.users, ids.chats).then(
|
|
55
|
+
(next) => {
|
|
56
|
+
if (!current) return
|
|
57
|
+
setAnswer(next)
|
|
58
|
+
setRequestError(undefined)
|
|
59
|
+
},
|
|
60
|
+
(error: unknown) => { if (current) setRequestError(error instanceof Error ? error.message : String(error)) },
|
|
61
|
+
)
|
|
62
|
+
return () => { current = false }
|
|
63
|
+
// `key` carries `ids`: a new snapshot with the same ids does not re-ask.
|
|
64
|
+
}, [key])
|
|
65
|
+
return useMemo(() => {
|
|
66
|
+
const warning = requestError ?? answer.warning
|
|
67
|
+
return {
|
|
68
|
+
user: (userId: string) => answer.users[userId],
|
|
69
|
+
chat: (chatId: string) => answer.chats[chatId],
|
|
70
|
+
...(warning === undefined ? {} : { warning }),
|
|
71
|
+
}
|
|
72
|
+
}, [answer, requestError])
|
|
73
|
+
}
|