@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0-beta.4",
4
- "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
3
+ "version": "0.5.0-beta.6",
4
+ "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 左侧栏提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/modestoma/dsh_channels.git"
package/src/client/api.ts CHANGED
@@ -16,7 +16,10 @@ import {
16
16
  CREDENTIALS_ROUTE,
17
17
  PANEL_ROUTE,
18
18
  PICK_DIRECTORY_ROUTE,
19
+ PROFILES_MAX_IDS,
20
+ PROFILES_ROUTE,
19
21
  RECONNECT_ROUTE,
22
+ SESSION_ORIGIN_ROUTE,
20
23
  STATUS_ROUTE,
21
24
  } from '../settings/contract.ts'
22
25
  import type {
@@ -28,12 +31,14 @@ import type {
28
31
  ClearCredentialResponse,
29
32
  CredentialField,
30
33
  EditableValues,
34
+ FeishuProfilesWire,
31
35
  PanelWire,
32
36
  PickDirectoryResponse,
33
37
  ReconnectResponse,
34
38
  SaveConfigResponse,
35
39
  SaveCredentialsRequest,
36
40
  SaveCredentialsResponse,
41
+ SessionOriginResponse,
37
42
  } from '../settings/contract.ts'
38
43
 
39
44
  function messageOf(error: unknown): string {
@@ -51,9 +56,15 @@ function errorFromBody(status: number, text: string): string {
51
56
  return `HTTP ${status}`
52
57
  }
53
58
 
54
- async function request(path: string, init?: RequestInit): Promise<Response> {
59
+ /**
60
+ * Fetch one Host route. Routes are registered as absolute pathnames, but the
61
+ * browser addresses them relative to the DSH document (`<base href="./">`), so
62
+ * a reverse proxy that mounts DSH under a prefix such as `/dsh/` still routes
63
+ * them to DSH with the login cookie it scoped to that prefix.
64
+ */
65
+ async function request(route: string, init?: RequestInit): Promise<Response> {
55
66
  try {
56
- return await fetch(path, {
67
+ return await fetch(route.slice(1), {
57
68
  cache: 'no-store',
58
69
  credentials: 'same-origin',
59
70
  ...init,
@@ -80,6 +91,38 @@ export async function fetchStatus(): Promise<ChannelStatusWire> {
80
91
  return readJson<ChannelStatusWire>(await request(STATUS_ROUTE))
81
92
  }
82
93
 
94
+ /** Feishu origin of one Session (`origin: null` when Feishu never routed it). */
95
+ export async function fetchSessionOrigin(sessionId: string): Promise<SessionOriginResponse> {
96
+ const query = new URLSearchParams({ sessionId }).toString()
97
+ return readJson<SessionOriginResponse>(await request(`${SESSION_ORIGIN_ROUTE}?${query}`))
98
+ }
99
+
100
+ /**
101
+ * Live Feishu names and avatars of the given users and chats, split into
102
+ * requests of at most {@link PROFILES_MAX_IDS} ids per list and merged.
103
+ * @param userIds - user `user_id`s.
104
+ * @param chatIds - chat `chat_id`s.
105
+ * @returns the profiles Feishu resolved, by id, with every request's warning.
106
+ */
107
+ export async function fetchFeishuProfiles(userIds: readonly string[], chatIds: readonly string[]): Promise<FeishuProfilesWire> {
108
+ const requests: Array<Promise<FeishuProfilesWire>> = []
109
+ const rounds = Math.max(1, Math.ceil(userIds.length / PROFILES_MAX_IDS), Math.ceil(chatIds.length / PROFILES_MAX_IDS))
110
+ for (let round = 0; round < rounds; round += 1) {
111
+ const window = (ids: readonly string[]): string => ids.slice(round * PROFILES_MAX_IDS, (round + 1) * PROFILES_MAX_IDS).join(',')
112
+ const query = new URLSearchParams({ users: window(userIds), chats: window(chatIds) }).toString()
113
+ requests.push(request(`${PROFILES_ROUTE}?${query}`).then(response => readJson<FeishuProfilesWire>(response)))
114
+ }
115
+ const merged: FeishuProfilesWire = { users: {}, chats: {} }
116
+ const warnings = new Set<string>()
117
+ for (const part of await Promise.all(requests)) {
118
+ Object.assign(merged.users, part.users)
119
+ Object.assign(merged.chats, part.chats)
120
+ if (part.warning !== undefined) warnings.add(part.warning)
121
+ }
122
+ if (warnings.size > 0) merged.warning = [...warnings].join(';')
123
+ return merged
124
+ }
125
+
83
126
  /**
84
127
  * Model-selector catalog for one route: providers, the provider's models, the
85
128
  * model's reasoning efforts, and the preset roster. The client re-queries it
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Round Feishu avatar: the user or chat image when Feishu returned one,
3
+ * otherwise (and when the image fails to load) the first character of the
4
+ * display text.
5
+ *
6
+ * @module dsh-channels/client/avatar
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+
11
+ /**
12
+ * One avatar badge. Decorative: the adjacent label names the user or chat.
13
+ * @param props - `text` supplies the fallback initial, `url` the image, `size` the diameter in px (default 26).
14
+ * @returns the badge.
15
+ */
16
+ export function Avatar(props: { text: string; url?: string; size?: number }): JSX.Element {
17
+ const { text, url, size = 26 } = props
18
+ const [failed, setFailed] = useState(false)
19
+ useEffect(() => { setFailed(false) }, [url])
20
+ const style = { width: size, height: size, fontSize: Math.round(size * 0.46) }
21
+ if (url !== undefined && !failed) {
22
+ return (
23
+ <img
24
+ className="dsh-channels-avatar"
25
+ style={style}
26
+ src={url}
27
+ alt=""
28
+ aria-hidden="true"
29
+ referrerPolicy="no-referrer"
30
+ loading="lazy"
31
+ onError={() => setFailed(true)}
32
+ />
33
+ )
34
+ }
35
+ return <span className="dsh-channels-avatar" style={style} aria-hidden="true">{Array.from(text)[0] ?? '?'}</span>
36
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 左侧栏「飞书渠道」行的装饰图标;行本身负责可访问名称(列表注册的 label)。
3
+ *
4
+ * @module dsh-channels/client/icon
5
+ */
6
+
7
+ /** Owner share of `sidebar.panellist`: requested square edge and selection state. */
8
+ export interface FeishuChannelsIconProps {
9
+ size: number
10
+ active: boolean
11
+ }
12
+
13
+ /**
14
+ * Paper-plane glyph drawn in `currentColor`, so it follows the row's text color.
15
+ * @param props - the sidebar's icon share.
16
+ * @returns decorative icon.
17
+ */
18
+ export function FeishuChannelsIcon({ size }: FeishuChannelsIconProps): JSX.Element {
19
+ return (
20
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
21
+ <path d="M21 3 3.5 10.2a.6.6 0 0 0 0 1.1l6.6 2.6 2.6 6.6a.6.6 0 0 0 1.1 0L21 3Z" />
22
+ <path d="m10.1 13.9 4.4-4.4" />
23
+ </svg>
24
+ )
25
+ }
@@ -1,35 +1,52 @@
1
1
  /**
2
- * dsh-channels client half: 设置 →「飞书渠道」分区。
2
+ * dsh-channels client half: 左侧栏全局入口「飞书渠道」+ 主区整页设置面板,
3
+ * 以及对话内的「飞书来源」(会话头部入口 + 右侧栏标签页)。
3
4
  *
4
5
  * 只依赖平台核心的 `slots` 服务与 react(bundle 里 react 走平台模块表,
5
- * 其余全部内联)。页面数据来自 Host 半侧的 `/dsh-channels/*` 路由,因此
6
- * 不需要 typert 契约,也不 import 任何 DSH 客户端包。
6
+ * 其余全部内联);右侧栏服务存在时才注册来源标签页。页面数据来自 Host
7
+ * 半侧的 `/dsh-channels/*` 路由,因此不需要 typert 契约,也不 import 任何
8
+ * DSH 客户端包。
7
9
  *
8
10
  * @module dsh-channels/client
9
11
  */
10
12
 
11
13
  import { PANEL_COPY } from '../settings/contract.ts'
12
- import { FeishuChannelsSection } from './panel/index.tsx'
14
+ import { FeishuChannelsIcon } from './icon.tsx'
15
+ import { ORIGIN_TAB_ID, ORIGIN_TAB_KIND, ORIGIN_TAB_TITLE, OriginHeaderAction, OriginTab } from './origin.tsx'
16
+ import { FeishuChannelsPage } from './panel/index.tsx'
13
17
  import { installStyles } from './styles.ts'
14
18
 
15
- // 平台 client runtime 的最小结构声明:只声明本 bundle 用到的两个能力。
16
- // 与安装的 DSH Web 版本(0.2.x)的 slots 运行时 API 一致。
19
+ // 平台 client runtime 的最小结构声明:只声明本 bundle 用到的能力。
20
+ // 与安装的 DSH Web 版本(0.2.x)的 slots 运行时 API 一致:list 槽按 `id`
21
+ // 注册,keyed 槽按 `key` 注册;`inject` 回调按会话作用域槽收到 sessionId。
17
22
  export interface ClientSlots {
18
23
  inject(slot: string, contribution: () => unknown): void
19
24
  register(
20
- declaration: {
21
- name: string
22
- id: string
23
- order?: number
24
- label: () => string
25
- },
26
- component: (props: { close?: () => void }) => unknown,
25
+ declaration:
26
+ | { name: string; id: string; order?: number; label?: () => string; inject?: (sessionId: string) => object }
27
+ | { name: string; key: string },
28
+ component: (props: never) => unknown,
27
29
  ): () => void
28
30
  }
29
31
 
32
+ /** Static right-sidebar tab type (`ctx.sidebarRightTabs.register`). */
33
+ export interface SidebarRightTabDefinition {
34
+ id: string
35
+ kind: string
36
+ title: (address: string) => string
37
+ }
38
+
39
+ /** Right-sidebar services the origin tab needs. */
40
+ export interface SidebarRightScope extends ClientContext {
41
+ sidebarRightTabs: { register(definition: SidebarRightTabDefinition): () => void }
42
+ sidebarRight: { openTab(kind: string): void }
43
+ }
44
+
30
45
  export interface ClientContext {
31
46
  slots: ClientSlots
32
47
  effect?(callback: () => void | (() => void), label?: string): void
48
+ /** Run `callback` in a child scope while every named service is available. */
49
+ inject?(deps: string[], callback: (scope: SidebarRightScope) => void): void
33
50
  }
34
51
 
35
52
  /** Cordis client plugin name. */
@@ -38,24 +55,62 @@ export const name = 'dsh-channels'
38
55
  /** Required client services: the Slots registry is part of the web shell core. */
39
56
  export const inject = ['slots']
40
57
 
41
- /** Section id and nav order (built-ins: account -10, general 0, models 10, plugins 15, agent-presets 20). */
42
- export const SETTINGS_SECTION_ID = 'feishu-channels'
43
- export const SETTINGS_SECTION_ORDER = 40
58
+ /**
59
+ * Main panel id: the `sidebar.panellist` row id and the matching `main` slot key.
60
+ * Must not change once released: DSH keys navigation state by it.
61
+ */
62
+ export const PANEL_ID = 'feishu-channels'
63
+ /** Left-sidebar row order (built-ins: tasks 10; Plugins is pinned last by the sidebar). */
64
+ export const PANEL_ORDER = 40
65
+ /** Session header action order (after the preset label -10 and subagents 10). */
66
+ export const ORIGIN_ACTION_ORDER = 30
44
67
 
45
68
  /**
46
- * Mount the settings section and its stylesheet.
69
+ * Mount the left-sidebar entry, its main-column page, the stylesheet, and —
70
+ * while the right sidebar is present — the Feishu origin header entry and tab.
47
71
  * @param ctx - client plugin context carrying the slots registry.
48
72
  */
49
73
  export function apply(ctx: ClientContext): void {
50
74
  if (typeof ctx.effect === 'function') {
51
- ctx.effect(() => installStyles(), 'dsh-channels: settings styles')
75
+ ctx.effect(() => installStyles(), 'dsh-channels: panel styles')
52
76
  } else {
53
77
  installStyles()
54
78
  }
55
- ctx.slots.inject('settings.section', () => ctx.slots.register({
56
- name: 'settings.section',
57
- id: SETTINGS_SECTION_ID,
58
- order: SETTINGS_SECTION_ORDER,
79
+ ctx.slots.inject('main', () => ctx.slots.register({
80
+ name: 'main',
81
+ key: PANEL_ID,
82
+ }, FeishuChannelsPage))
83
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
84
+ name: 'sidebar.panellist',
85
+ id: PANEL_ID,
86
+ order: PANEL_ORDER,
59
87
  label: () => PANEL_COPY.title,
60
- }, FeishuChannelsSection))
88
+ }, FeishuChannelsIcon))
89
+ ctx.inject?.(['sidebarRightTabs', 'sidebarRight'], mountOrigin)
90
+ }
91
+
92
+ /**
93
+ * Register the origin tab type, its body and title, and the header entry that
94
+ * opens it. Everything lives as long as the right-sidebar services do.
95
+ * @param scope - child context holding the right-sidebar services.
96
+ */
97
+ export function mountOrigin(scope: SidebarRightScope): void {
98
+ const definition: SidebarRightTabDefinition = {
99
+ id: ORIGIN_TAB_ID,
100
+ kind: ORIGIN_TAB_KIND,
101
+ title: () => ORIGIN_TAB_TITLE,
102
+ }
103
+ scope.effect?.(() => scope.sidebarRightTabs.register(definition), 'dsh-channels: origin tab type')
104
+ scope.slots.inject('sidebar.right.pane.tab', () => scope.slots.register({
105
+ name: 'sidebar.right.pane.tab',
106
+ key: ORIGIN_TAB_ID,
107
+ }, OriginTab))
108
+ scope.slots.inject('conversation.session.header.actions', () => scope.slots.register({
109
+ name: 'conversation.session.header.actions',
110
+ id: 'dsh-channels-origin',
111
+ order: ORIGIN_ACTION_ORDER,
112
+ inject: () => ({
113
+ openOrigin: () => { scope.sidebarRight.openTab(ORIGIN_TAB_KIND) },
114
+ }),
115
+ }, OriginHeaderAction))
61
116
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 浏览器半区的 `number` 字段辅助:按 Host 描述的 `scale` 在显示单位与存储
3
+ * 单位之间换算、保存前校验上下界,以及字节数的 MiB 读数。
4
+ *
5
+ * 不含 JSX/React,便于单测。Host 保存时按 config schema 复核。
6
+ *
7
+ * @module dsh-channels/client/number-field
8
+ */
9
+
10
+ import { fillCopy, PANEL_COPY } from '../settings/contract.ts'
11
+ import type { ConfigValue, EditableFieldWire } from '../settings/contract.ts'
12
+
13
+ /** Drop float noise from `stored / scale` (e.g. 0.30000000000000004). */
14
+ function tidy(value: number): number {
15
+ return Number(value.toPrecision(12))
16
+ }
17
+
18
+ /**
19
+ * Value the input shows for one field.
20
+ *
21
+ * @param field - The Host descriptor (`kind: 'number'`).
22
+ * @param value - Draft (the typed string) or effective value (stored units).
23
+ * @returns The input text.
24
+ */
25
+ export function numberInputValue(field: EditableFieldWire, value: ConfigValue | undefined): string {
26
+ if (typeof value === 'string') return value
27
+ if (typeof value !== 'number') return ''
28
+ return String(field.scale === undefined ? value : tidy(value / field.scale))
29
+ }
30
+
31
+ /**
32
+ * Input `step` in display units.
33
+ *
34
+ * @param field - The Host descriptor (`kind: 'number'`).
35
+ * @returns The step, or `undefined` when the Host gives none.
36
+ */
37
+ export function numberInputStep(field: EditableFieldWire): number | undefined {
38
+ if (field.step === undefined) return undefined
39
+ return field.scale === undefined ? field.step : tidy(field.step / field.scale)
40
+ }
41
+
42
+ /** One bound in display units with the field's unit appended. */
43
+ function boundLabel(field: EditableFieldWire, stored: number): string {
44
+ const shown = field.scale === undefined ? stored : tidy(stored / field.scale)
45
+ return field.unit === undefined ? String(shown) : `${shown} ${field.unit}`
46
+ }
47
+
48
+ /**
49
+ * Turn one number draft into its save payload, checking the Host bounds.
50
+ *
51
+ * @param field - The Host descriptor (`kind: 'number'`).
52
+ * @param raw - The draft: the typed string, or a stored-unit number.
53
+ * @returns The stored-unit value (`''` clears an optional field), or the reason it is rejected.
54
+ */
55
+ export function numberDraftValue(
56
+ field: EditableFieldWire,
57
+ raw: ConfigValue | undefined,
58
+ ): { ok: true; value: number | '' } | { ok: false; message: string } {
59
+ let stored: number
60
+ if (typeof raw === 'number') {
61
+ stored = raw
62
+ } else {
63
+ const text = typeof raw === 'string' ? raw.trim() : ''
64
+ if (text === '') {
65
+ return field.optional === true ? { ok: true, value: '' } : { ok: false, message: PANEL_COPY.numberInvalid }
66
+ }
67
+ const parsed = Number(text)
68
+ if (!Number.isFinite(parsed)) return { ok: false, message: PANEL_COPY.numberInvalid }
69
+ stored = field.scale === undefined ? parsed : Math.round(parsed * field.scale)
70
+ }
71
+ if (field.min !== undefined && stored < field.min) {
72
+ return { ok: false, message: fillCopy(PANEL_COPY.numberMin, { n: boundLabel(field, field.min) }) }
73
+ }
74
+ if (field.max !== undefined && stored > field.max) {
75
+ return { ok: false, message: fillCopy(PANEL_COPY.numberMax, { n: boundLabel(field, field.max) }) }
76
+ }
77
+ return { ok: true, value: stored }
78
+ }
79
+
80
+ /**
81
+ * Human reading of a byte count, e.g. `20 MiB`.
82
+ *
83
+ * @param text - The input text (bytes).
84
+ * @returns The reading, or `undefined` when the text is not a positive number.
85
+ */
86
+ export function bytesReading(text: string): string | undefined {
87
+ const bytes = Number(text)
88
+ if (text.trim() === '' || !Number.isFinite(bytes) || bytes <= 0) return undefined
89
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
90
+ let value = bytes
91
+ let unit = 0
92
+ while (value >= 1024 && unit < units.length - 1) {
93
+ value /= 1024
94
+ unit += 1
95
+ }
96
+ return `${Number(value.toFixed(value >= 100 || unit === 0 ? 0 : 1))} ${units[unit]}`
97
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 按会话缓存的飞书来源查询,会话头部入口与右侧栏标签页共用。
3
+ *
4
+ * @module dsh-channels/client/origin-cache
5
+ */
6
+
7
+ import type { SessionOriginWire } from '../settings/contract.ts'
8
+ import { fetchSessionOrigin } from './api.ts'
9
+
10
+ /**
11
+ * How long a "not from Feishu" answer is reused. A Session shows up in the
12
+ * Web list before its Feishu route is written, so an early null must expire.
13
+ */
14
+ const NULL_ORIGIN_TTL_MS = 15_000
15
+
16
+ interface Lookup {
17
+ pending: Promise<SessionOriginWire | null>
18
+ /** Epoch ms when the Host answered null; absent while pending or for an origin. */
19
+ nullAt?: number
20
+ }
21
+
22
+ /** One settled or pending origin lookup per Session; failures are not kept. */
23
+ const lookups = new Map<string, Lookup>()
24
+
25
+ /**
26
+ * Origin of one Session through the shared cache. An origin is kept for the
27
+ * page's lifetime; a null answer only for {@link NULL_ORIGIN_TTL_MS}.
28
+ * @param sessionId - DSH session id.
29
+ * @param refresh - drop the cached answer and ask the Host again.
30
+ * @returns the origin, or null for a Session Feishu never routed.
31
+ */
32
+ export function loadSessionOrigin(sessionId: string, refresh = false): Promise<SessionOriginWire | null> {
33
+ const cached = refresh ? undefined : lookups.get(sessionId)
34
+ if (cached !== undefined && (cached.nullAt === undefined || Date.now() - cached.nullAt < NULL_ORIGIN_TTL_MS)) {
35
+ return cached.pending
36
+ }
37
+ const pending = fetchSessionOrigin(sessionId).then(response => response.origin)
38
+ const lookup: Lookup = { pending }
39
+ lookups.set(sessionId, lookup)
40
+ pending.then(
41
+ (origin) => { if (origin === null) lookup.nullAt = Date.now() },
42
+ () => { if (lookups.get(sessionId) === lookup) lookups.delete(sessionId) },
43
+ )
44
+ return pending
45
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 对话里的「飞书来源」:会话头部的入口按钮(只在飞书发起的会话里出现)与
3
+ * 右侧栏的来源标签页。两者共用一份按会话缓存的来源查询。
4
+ *
5
+ * @module dsh-channels/client/origin
6
+ */
7
+
8
+ import { useCallback, useEffect, useState } from 'react'
9
+ import type { SessionOriginWire } from '../settings/contract.ts'
10
+ import { Avatar } from './avatar.tsx'
11
+ import { loadSessionOrigin } from './origin-cache.ts'
12
+ import { FeishuChannelsIcon } from './icon.tsx'
13
+
14
+ /** Right-sidebar tab kind opened by the header button. */
15
+ export const ORIGIN_TAB_KIND = 'feishu-origin'
16
+ /** Tab-type implementation id and keyed body/title slot key. */
17
+ export const ORIGIN_TAB_ID = '@modesto_ma/dsh-channels/origin'
18
+ /** Tab chip text. */
19
+ export const ORIGIN_TAB_TITLE = '飞书来源'
20
+
21
+ type OriginState =
22
+ | { phase: 'loading' }
23
+ | { phase: 'ready'; origin: SessionOriginWire | null }
24
+ | { phase: 'error'; message: string }
25
+
26
+ /**
27
+ * Load one Session's origin; `reload` asks the Host again. `fresh` skips the
28
+ * shared cache on the first load too, so names and avatars are current.
29
+ */
30
+ function useSessionOrigin(sessionId: string, fresh = false): [OriginState, () => void] {
31
+ const [state, setState] = useState<OriginState>({ phase: 'loading' })
32
+ // A reload is tied to the Session it was asked for, so switching Sessions
33
+ // never shows the previous Session's origin while the new one loads.
34
+ const [reloadOf, setReloadOf] = useState<{ sessionId: string; ticket: number }>({ sessionId, ticket: 0 })
35
+ const ticket = reloadOf.sessionId === sessionId ? reloadOf.ticket : 0
36
+ useEffect(() => {
37
+ let live = true
38
+ setState(current => (current.phase === 'ready' && ticket > 0 ? current : { phase: 'loading' }))
39
+ loadSessionOrigin(sessionId, fresh || ticket > 0).then(
40
+ origin => { if (live) setState({ phase: 'ready', origin }) },
41
+ (error: unknown) => {
42
+ if (live) setState({ phase: 'error', message: error instanceof Error ? error.message : String(error) })
43
+ },
44
+ )
45
+ return () => { live = false }
46
+ }, [fresh, sessionId, ticket])
47
+ const reload = useCallback(() => {
48
+ setReloadOf(current => ({ sessionId, ticket: current.sessionId === sessionId ? current.ticket + 1 : 1 }))
49
+ }, [sessionId])
50
+ return [state, reload]
51
+ }
52
+
53
+ /** Header chip text: group name, or 私聊 with the peer's name. */
54
+ function sourceLabel(origin: SessionOriginWire): string {
55
+ if (origin.chatType === 'p2p') return origin.senderName === undefined ? '飞书私聊' : `私聊 · ${origin.senderName}`
56
+ return origin.chatName === undefined ? '飞书群聊' : origin.chatName
57
+ }
58
+
59
+ /** Tab heading: the group, or the private-chat peer, with its avatar. */
60
+ function originHeading(origin: SessionOriginWire): { title: string; avatarUrl?: string } {
61
+ if (origin.chatType === 'p2p') {
62
+ return { title: origin.senderName ?? '飞书私聊', ...(origin.senderAvatarUrl === undefined ? {} : { avatarUrl: origin.senderAvatarUrl }) }
63
+ }
64
+ return { title: origin.chatName ?? '飞书群聊', ...(origin.chatAvatarUrl === undefined ? {} : { avatarUrl: origin.chatAvatarUrl }) }
65
+ }
66
+
67
+ /** Times a mounted header re-asks after a null answer, and the delay between asks. */
68
+ const HEADER_NULL_RETRIES = 3
69
+ const HEADER_NULL_RETRY_MS = 5_000
70
+
71
+ /** Props of the header entry: the session standard kit's `sessionId`. */
72
+ export interface OriginHeaderActionProps {
73
+ sessionId: string
74
+ /** Open the origin tab in this Session's right sidebar. */
75
+ openOrigin: () => void
76
+ }
77
+
78
+ /**
79
+ * Header button shown only for Sessions Feishu routed; renders nothing for
80
+ * every other Session and while the lookup is pending or failed.
81
+ * @param props - session id and the tab opener.
82
+ * @returns the button or null.
83
+ */
84
+ export function OriginHeaderAction({ sessionId, openOrigin }: OriginHeaderActionProps): JSX.Element | null {
85
+ const [state, reload] = useSessionOrigin(sessionId)
86
+ const [retries, setRetries] = useState(0)
87
+ useEffect(() => { setRetries(0) }, [sessionId])
88
+ // A Session opened while Feishu is still creating it answers null until its
89
+ // route is written; look again a few times while the header stays mounted.
90
+ const waiting = state.phase === 'ready' && state.origin === null && retries < HEADER_NULL_RETRIES
91
+ useEffect(() => {
92
+ if (!waiting) return
93
+ const timer = setTimeout(() => {
94
+ setRetries(value => value + 1)
95
+ reload()
96
+ }, HEADER_NULL_RETRY_MS)
97
+ return () => { clearTimeout(timer) }
98
+ }, [waiting, reload])
99
+ if (state.phase !== 'ready' || state.origin === null) return null
100
+ const label = sourceLabel(state.origin)
101
+ return (
102
+ <button
103
+ type="button"
104
+ className="dsh-channels-origin-chip"
105
+ title={`来自${label},点击查看飞书来源`}
106
+ aria-label={`飞书来源:${label}`}
107
+ onClick={openOrigin}
108
+ >
109
+ <FeishuChannelsIcon size={13} active={false} />
110
+ <span className="dsh-channels-origin-chip__text">{label}</span>
111
+ </button>
112
+ )
113
+ }
114
+
115
+ /** Props of the tab body: the session standard kit's `sessionId`. */
116
+ export interface OriginTabProps {
117
+ sessionId: string
118
+ }
119
+
120
+ function formatTime(ms: number): string {
121
+ const date = new Date(ms)
122
+ const pad = (value: number): string => String(value).padStart(2, '0')
123
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
124
+ }
125
+
126
+ /**
127
+ * Right-sidebar body: where this Session came from in Feishu.
128
+ * @param props - the tab's Session.
129
+ * @returns the origin page.
130
+ */
131
+ export function OriginTab({ sessionId }: OriginTabProps): JSX.Element {
132
+ const [state, reload] = useSessionOrigin(sessionId, true)
133
+ return (
134
+ <div className="dsh-channels-origin">
135
+ {state.phase === 'loading' ? <div className="dsh-channels-origin__muted">正在读取飞书来源…</div> : null}
136
+ {state.phase === 'error'
137
+ ? (
138
+ <div className="dsh-channels-origin__error">
139
+ <span>读取飞书来源失败:{state.message}</span>
140
+ <button type="button" className="dsh-channels-origin__link" onClick={reload}>重试</button>
141
+ </div>
142
+ )
143
+ : null}
144
+ {state.phase === 'ready' && state.origin === null
145
+ ? (
146
+ <div className="dsh-channels-origin__muted">
147
+ 这个会话不是从飞书发起的。
148
+ <button type="button" className="dsh-channels-origin__link" onClick={reload}>刷新</button>
149
+ </div>
150
+ )
151
+ : null}
152
+ {state.phase === 'ready' && state.origin !== null ? <OriginDetails origin={state.origin} onRefresh={reload} /> : null}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ function OriginDetails({ origin, onRefresh }: { origin: SessionOriginWire; onRefresh: () => void }): JSX.Element {
158
+ const p2p = origin.chatType === 'p2p'
159
+ const rows: Array<[string, JSX.Element | string]> = [
160
+ ['来源', p2p ? '私聊' : (origin.kind === 'thread' ? '群聊 · 话题' : '群聊')],
161
+ ]
162
+ // The heading already names a resolved group; an unnamed one shows its id.
163
+ if (!p2p && origin.chatName === undefined) {
164
+ rows.push(['群 ID', <span className="dsh-channels-origin__mono">{origin.chatId}</span>])
165
+ }
166
+ rows.push([origin.shared ? '请求人' : '发起人', (
167
+ <span className="dsh-channels-origin__who">
168
+ <Avatar text={origin.senderName ?? origin.senderUserId} url={origin.senderAvatarUrl} size={20} />
169
+ {origin.senderName === undefined ? null : <span>{origin.senderName}</span>}
170
+ <span className="dsh-channels-origin__mono dsh-channels-origin__muted">{origin.senderUserId}</span>
171
+ </span>
172
+ )])
173
+ const heading = originHeading(origin)
174
+ if (origin.shared) rows.push(['说明', '日志平台分析话题,群内成员都可以继续'])
175
+ rows.push(['开始于', formatTime(origin.createdAt)])
176
+ rows.push(origin.bound && origin.lastActivityAt !== undefined
177
+ ? ['最近活跃', formatTime(origin.lastActivityAt)]
178
+ : ['状态', '飞书侧已切换到新会话或已过期'])
179
+ return (
180
+ <>
181
+ <div className="dsh-channels-origin__head">
182
+ {heading.avatarUrl === undefined
183
+ ? <FeishuChannelsIcon size={16} active={false} />
184
+ : <Avatar text={heading.title} url={heading.avatarUrl} size={28} />}
185
+ <span className="dsh-channels-origin__title">{heading.title}</span>
186
+ </div>
187
+ <dl className="dsh-channels-origin__list">
188
+ {rows.map(([label, value]) => (
189
+ <div key={label} className="dsh-channels-origin__row">
190
+ <dt>{label}</dt>
191
+ <dd>{value}</dd>
192
+ </div>
193
+ ))}
194
+ </dl>
195
+ <div className="dsh-channels-origin__actions">
196
+ <a className="dsh-channels-origin__button" href={origin.chatLink} target="_blank" rel="noreferrer">在飞书中打开</a>
197
+ <button type="button" className="dsh-channels-origin__link" onClick={onRefresh}>刷新</button>
198
+ </div>
199
+ </>
200
+ )
201
+ }