@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.
Files changed (41) hide show
  1. package/README.md +49 -28
  2. package/docs/attachment-pipeline-v2.md +1 -1
  3. package/docs/feishu-onboarding.md +6 -5
  4. package/lib/client.js +1874 -1510
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +736 -353
  7. package/lib/index.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/client/api.ts +45 -2
  10. package/src/client/avatar.tsx +36 -0
  11. package/src/client/icon.tsx +25 -0
  12. package/src/client/index.ts +78 -23
  13. package/src/client/number-field.ts +97 -0
  14. package/src/client/origin-cache.ts +45 -0
  15. package/src/client/origin.tsx +201 -0
  16. package/src/client/panel/access-tab.tsx +90 -21
  17. package/src/client/panel/advanced-tab.tsx +59 -68
  18. package/src/client/panel/connection-tab.tsx +147 -164
  19. package/src/client/panel/credentials-card.tsx +124 -87
  20. package/src/client/panel/fields-tab.tsx +27 -0
  21. package/src/client/panel/index.tsx +77 -54
  22. package/src/client/panel/profiles.ts +73 -0
  23. package/src/client/panel/shared.tsx +358 -253
  24. package/src/client/panel/whitelist.tsx +203 -0
  25. package/src/client/panel/wiki-tab.tsx +33 -39
  26. package/src/client/styles.ts +132 -92
  27. package/src/domain.ts +42 -0
  28. package/src/feishu/scopes.ts +2 -1
  29. package/src/feishu-channel.ts +87 -0
  30. package/src/index.ts +1 -1
  31. package/src/routing/inbound.ts +13 -5
  32. package/src/service.ts +146 -3
  33. package/src/session/routes.ts +45 -4
  34. package/src/settings/contract.ts +265 -152
  35. package/src/settings/routes.ts +47 -0
  36. package/src/settings/surface.ts +201 -197
  37. package/src/client/panel/access-card.tsx +0 -329
  38. package/src/client/panel/analyze-token-card.tsx +0 -70
  39. package/src/client/panel/model-tab.tsx +0 -23
  40. package/src/client/panel/sessions-tab.tsx +0 -24
  41. 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
- * 设置 →「飞书渠道」页面:数据加载、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 组件只接收渲染所需的数据与
7
- * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
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 { ModelTab } from './model-tab.tsx'
52
- import { SessionsTab } from './sessions-tab.tsx'
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
- phaseSummary,
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
- /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
73
- export interface FeishuChannelsSectionProps {
74
- close?: () => void
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
- /** Page shell: one panel payload, one status poll, one global config draft. */
78
- export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.Element | null {
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 keys owned by one tab (nothing is written). */
320
- const discardTab = useCallback((tab: PanelTabId): void => {
329
+ /** Drop the whole draft (nothing is written). */
330
+ const discardDraft = useCallback((): void => {
321
331
  if (panel === null) return
322
- const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
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
- if (tab === 'model') {
333
- catalogTicket.current += 1
334
- setCatalog(catalogFromFields(panel.config.fields))
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, discardTab, dirtyCount, draft, hostBusy, issues, onPickDirectory, onSave, panel, renderedFields, updateDraft])
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 activeTab={activeTab} dirtyTabs={dirtyTabs} onChange={setActiveTab} />
717
- {notice === null ? null : <NoticeBar notice={notice} />}
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' ? <SessionsTab form={form} analyzeToken={analyzeToken} /> : null}
735
- {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
736
- {activeTab === 'workspace'
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 + phase badge + refresh, visible on every tab. */
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" title={PANEL_COPY.description}>
806
+ <header className="dsh-channels-cfg__panelHead">
786
807
  <div className="dsh-channels-cfg__headRow">
787
- <h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
788
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`}>
789
- <span className="dsh-channels-cfg__dot" />
790
- {badge.text}
791
- </span>
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
- title={PANEL_COPY.dirtyCount}
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
+ }