@modesto_ma/dsh-channels 0.5.0-beta.5 → 0.5.0-beta.7

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/lib/client.js CHANGED
@@ -4,8 +4,8 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
- let react = require("react");
8
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
9
  //#region src/settings/contract.ts
10
10
  /**
11
11
  * Web 设置面板的线上契约:Host 半侧的路由/面板数据与 Browser 半侧的渲染共用。
@@ -44,6 +44,14 @@ window.__ModuleLoader__.load({
44
44
  * [`EditableFieldWire.browse`],保留手写输入。
45
45
  */
46
46
  const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
47
+ /** 会话的飞书来源(GET/HEAD,`?sessionId=`):对话右侧栏「飞书来源」标签与会话头部入口使用。 */
48
+ const SESSION_ORIGIN_ROUTE = "/dsh-channels/session-origin";
49
+ /**
50
+ * `GET PROFILES_ROUTE?users=<user_id,...>&chats=<chat_id,...>`: live Feishu
51
+ * names and avatars for the users and chats the panel shows. At most
52
+ * {@link PROFILES_MAX_IDS} ids per list.
53
+ */
54
+ const PROFILES_ROUTE = "/dsh-channels/profiles";
47
55
  /**
48
56
  * Top-level panel tabs, in render order. The panel shows the connection state
49
57
  * header above every tab, so switching tabs never hides whether the bridge is
@@ -242,7 +250,10 @@ window.__ModuleLoader__.load({
242
250
  cleared: "已恢复为 profile 配置",
243
251
  debugModeWarning: "调试模式已打开:应用可用范围内的任何人都能和机器人对话。用完请在「访问授权」关闭。",
244
252
  accessAdminMissingWarning: "没有可用的管理员,所有对话都会被拒绝。请在下方填写管理员 user_id(私聊机器人发送 /userid 获取)。",
245
- accessAdminOwnerSummary: "未单独设置,正在使用应用所有者 {id}",
253
+ accessAdminCurrent: "当前管理员",
254
+ accessAdminSourceConfig: "已配置",
255
+ accessAdminSourceOwner: "应用所有者",
256
+ profilesWarning: "部分飞书名字和头像未能读取:{reason}",
246
257
  accessNotLoaded: "飞书连接建立后加载授权列表",
247
258
  accessRequestsTitle: "待处理申请",
248
259
  accessGroupsTitle: "已授权群",
@@ -294,6 +305,69 @@ window.__ModuleLoader__.load({
294
305
  });
295
306
  }
296
307
  //#endregion
308
+ //#region src/client/icon.tsx
309
+ /**
310
+ * Paper-plane glyph drawn in `currentColor`, so it follows the row's text color.
311
+ * @param props - the sidebar's icon share.
312
+ * @returns decorative icon.
313
+ */
314
+ function FeishuChannelsIcon({ size }) {
315
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
316
+ width: size,
317
+ height: size,
318
+ viewBox: "0 0 24 24",
319
+ fill: "none",
320
+ stroke: "currentColor",
321
+ strokeWidth: 1.6,
322
+ strokeLinecap: "round",
323
+ strokeLinejoin: "round",
324
+ "aria-hidden": "true",
325
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("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" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m10.1 13.9 4.4-4.4" })]
326
+ });
327
+ }
328
+ //#endregion
329
+ //#region src/client/avatar.tsx
330
+ /**
331
+ * Round Feishu avatar: the user or chat image when Feishu returned one,
332
+ * otherwise (and when the image fails to load) the first character of the
333
+ * display text.
334
+ *
335
+ * @module dsh-channels/client/avatar
336
+ */
337
+ /**
338
+ * One avatar badge. Decorative: the adjacent label names the user or chat.
339
+ * @param props - `text` supplies the fallback initial, `url` the image, `size` the diameter in px (default 26).
340
+ * @returns the badge.
341
+ */
342
+ function Avatar(props) {
343
+ const { text, url, size = 26 } = props;
344
+ const [failed, setFailed] = (0, react.useState)(false);
345
+ (0, react.useEffect)(() => {
346
+ setFailed(false);
347
+ }, [url]);
348
+ const style = {
349
+ width: size,
350
+ height: size,
351
+ fontSize: Math.round(size * .46)
352
+ };
353
+ if (url !== void 0 && !failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
354
+ className: "dsh-channels-avatar",
355
+ style,
356
+ src: url,
357
+ alt: "",
358
+ "aria-hidden": "true",
359
+ referrerPolicy: "no-referrer",
360
+ loading: "lazy",
361
+ onError: () => setFailed(true)
362
+ });
363
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
364
+ className: "dsh-channels-avatar",
365
+ style,
366
+ "aria-hidden": "true",
367
+ children: Array.from(text)[0] ?? "?"
368
+ });
369
+ }
370
+ //#endregion
297
371
  //#region src/client/api.ts
298
372
  /**
299
373
  * Same-origin HTTP client for the settings panel. No typert contract: the
@@ -312,9 +386,15 @@ window.__ModuleLoader__.load({
312
386
  } catch {}
313
387
  return `HTTP ${status}`;
314
388
  }
315
- async function request(path, init) {
389
+ /**
390
+ * Fetch one Host route. Routes are registered as absolute pathnames, but the
391
+ * browser addresses them relative to the DSH document (`<base href="./">`), so
392
+ * a reverse proxy that mounts DSH under a prefix such as `/dsh/` still routes
393
+ * them to DSH with the login cookie it scoped to that prefix.
394
+ */
395
+ async function request(route, init) {
316
396
  try {
317
- return await fetch(path, {
397
+ return await fetch(route.slice(1), {
318
398
  cache: "no-store",
319
399
  credentials: "same-origin",
320
400
  ...init,
@@ -340,6 +420,41 @@ window.__ModuleLoader__.load({
340
420
  async function fetchStatus() {
341
421
  return readJson(await request(STATUS_ROUTE));
342
422
  }
423
+ /** Feishu origin of one Session (`origin: null` when Feishu never routed it). */
424
+ async function fetchSessionOrigin(sessionId) {
425
+ return readJson(await request(`${SESSION_ORIGIN_ROUTE}?${new URLSearchParams({ sessionId }).toString()}`));
426
+ }
427
+ /**
428
+ * Live Feishu names and avatars of the given users and chats, split into
429
+ * requests of at most {@link PROFILES_MAX_IDS} ids per list and merged.
430
+ * @param userIds - user `user_id`s.
431
+ * @param chatIds - chat `chat_id`s.
432
+ * @returns the profiles Feishu resolved, by id, with every request's warning.
433
+ */
434
+ async function fetchFeishuProfiles(userIds, chatIds) {
435
+ const requests = [];
436
+ const rounds = Math.max(1, Math.ceil(userIds.length / 200), Math.ceil(chatIds.length / 200));
437
+ for (let round = 0; round < rounds; round += 1) {
438
+ const window = (ids) => ids.slice(round * 200, (round + 1) * 200).join(",");
439
+ const query = new URLSearchParams({
440
+ users: window(userIds),
441
+ chats: window(chatIds)
442
+ }).toString();
443
+ requests.push(request(`${PROFILES_ROUTE}?${query}`).then((response) => readJson(response)));
444
+ }
445
+ const merged = {
446
+ users: {},
447
+ chats: {}
448
+ };
449
+ const warnings = /* @__PURE__ */ new Set();
450
+ for (const part of await Promise.all(requests)) {
451
+ Object.assign(merged.users, part.users);
452
+ Object.assign(merged.chats, part.chats);
453
+ if (part.warning !== void 0) warnings.add(part.warning);
454
+ }
455
+ if (warnings.size > 0) merged.warning = [...warnings].join(";");
456
+ return merged;
457
+ }
343
458
  /**
344
459
  * Model-selector catalog for one route: providers, the provider's models, the
345
460
  * model's reasoning efforts, and the preset roster. The client re-queries it
@@ -516,6 +631,257 @@ window.__ModuleLoader__.load({
516
631
  });
517
632
  }
518
633
  //#endregion
634
+ //#region src/client/origin-cache.ts
635
+ /**
636
+ * How long a "not from Feishu" answer is reused. A Session shows up in the
637
+ * Web list before its Feishu route is written, so an early null must expire.
638
+ */
639
+ const NULL_ORIGIN_TTL_MS = 15e3;
640
+ /** One settled or pending origin lookup per Session; failures are not kept. */
641
+ const lookups = /* @__PURE__ */ new Map();
642
+ /**
643
+ * Origin of one Session through the shared cache. An origin is kept for the
644
+ * page's lifetime; a null answer only for {@link NULL_ORIGIN_TTL_MS}.
645
+ * @param sessionId - DSH session id.
646
+ * @param refresh - drop the cached answer and ask the Host again.
647
+ * @returns the origin, or null for a Session Feishu never routed.
648
+ */
649
+ function loadSessionOrigin(sessionId, refresh = false) {
650
+ const cached = refresh ? void 0 : lookups.get(sessionId);
651
+ if (cached !== void 0 && (cached.nullAt === void 0 || Date.now() - cached.nullAt < NULL_ORIGIN_TTL_MS)) return cached.pending;
652
+ const pending = fetchSessionOrigin(sessionId).then((response) => response.origin);
653
+ const lookup = { pending };
654
+ lookups.set(sessionId, lookup);
655
+ pending.then((origin) => {
656
+ if (origin === null) lookup.nullAt = Date.now();
657
+ }, () => {
658
+ if (lookups.get(sessionId) === lookup) lookups.delete(sessionId);
659
+ });
660
+ return pending;
661
+ }
662
+ //#endregion
663
+ //#region src/client/origin.tsx
664
+ /**
665
+ * 对话里的「飞书来源」:会话头部的入口按钮(只在飞书发起的会话里出现)与
666
+ * 右侧栏的来源标签页。两者共用一份按会话缓存的来源查询。
667
+ *
668
+ * @module dsh-channels/client/origin
669
+ */
670
+ /** Right-sidebar tab kind opened by the header button. */
671
+ const ORIGIN_TAB_KIND = "feishu-origin";
672
+ /** Tab-type implementation id and keyed body/title slot key. */
673
+ const ORIGIN_TAB_ID = "@modesto_ma/dsh-channels/origin";
674
+ /** Tab chip text. */
675
+ const ORIGIN_TAB_TITLE = "飞书来源";
676
+ /**
677
+ * Load one Session's origin; `reload` asks the Host again. `fresh` skips the
678
+ * shared cache on the first load too, so names and avatars are current.
679
+ */
680
+ function useSessionOrigin(sessionId, fresh = false) {
681
+ const [state, setState] = (0, react.useState)({ phase: "loading" });
682
+ const [reloadOf, setReloadOf] = (0, react.useState)({
683
+ sessionId,
684
+ ticket: 0
685
+ });
686
+ const ticket = reloadOf.sessionId === sessionId ? reloadOf.ticket : 0;
687
+ (0, react.useEffect)(() => {
688
+ let live = true;
689
+ setState((current) => current.phase === "ready" && ticket > 0 ? current : { phase: "loading" });
690
+ loadSessionOrigin(sessionId, fresh || ticket > 0).then((origin) => {
691
+ if (live) setState({
692
+ phase: "ready",
693
+ origin
694
+ });
695
+ }, (error) => {
696
+ if (live) setState({
697
+ phase: "error",
698
+ message: error instanceof Error ? error.message : String(error)
699
+ });
700
+ });
701
+ return () => {
702
+ live = false;
703
+ };
704
+ }, [
705
+ fresh,
706
+ sessionId,
707
+ ticket
708
+ ]);
709
+ return [state, (0, react.useCallback)(() => {
710
+ setReloadOf((current) => ({
711
+ sessionId,
712
+ ticket: current.sessionId === sessionId ? current.ticket + 1 : 1
713
+ }));
714
+ }, [sessionId])];
715
+ }
716
+ /** Header chip text: group name, or 私聊 with the peer's name. */
717
+ function sourceLabel$1(origin) {
718
+ if (origin.chatType === "p2p") return origin.senderName === void 0 ? "飞书私聊" : `私聊 · ${origin.senderName}`;
719
+ return origin.chatName === void 0 ? "飞书群聊" : origin.chatName;
720
+ }
721
+ /** Tab heading: the group, or the private-chat peer, with its avatar. */
722
+ function originHeading(origin) {
723
+ if (origin.chatType === "p2p") return {
724
+ title: origin.senderName ?? "飞书私聊",
725
+ ...origin.senderAvatarUrl === void 0 ? {} : { avatarUrl: origin.senderAvatarUrl }
726
+ };
727
+ return {
728
+ title: origin.chatName ?? "飞书群聊",
729
+ ...origin.chatAvatarUrl === void 0 ? {} : { avatarUrl: origin.chatAvatarUrl }
730
+ };
731
+ }
732
+ /** Times a mounted header re-asks after a null answer, and the delay between asks. */
733
+ const HEADER_NULL_RETRIES = 3;
734
+ const HEADER_NULL_RETRY_MS = 5e3;
735
+ /**
736
+ * Header button shown only for Sessions Feishu routed; renders nothing for
737
+ * every other Session and while the lookup is pending or failed.
738
+ * @param props - session id and the tab opener.
739
+ * @returns the button or null.
740
+ */
741
+ function OriginHeaderAction({ sessionId, openOrigin }) {
742
+ const [state, reload] = useSessionOrigin(sessionId);
743
+ const [retries, setRetries] = (0, react.useState)(0);
744
+ (0, react.useEffect)(() => {
745
+ setRetries(0);
746
+ }, [sessionId]);
747
+ const waiting = state.phase === "ready" && state.origin === null && retries < HEADER_NULL_RETRIES;
748
+ (0, react.useEffect)(() => {
749
+ if (!waiting) return;
750
+ const timer = setTimeout(() => {
751
+ setRetries((value) => value + 1);
752
+ reload();
753
+ }, HEADER_NULL_RETRY_MS);
754
+ return () => {
755
+ clearTimeout(timer);
756
+ };
757
+ }, [waiting, reload]);
758
+ if (state.phase !== "ready" || state.origin === null) return null;
759
+ const label = sourceLabel$1(state.origin);
760
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
761
+ type: "button",
762
+ className: "dsh-channels-origin-chip",
763
+ title: `来自${label},点击查看飞书来源`,
764
+ "aria-label": `飞书来源:${label}`,
765
+ onClick: openOrigin,
766
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FeishuChannelsIcon, {
767
+ size: 13,
768
+ active: false
769
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
770
+ className: "dsh-channels-origin-chip__text",
771
+ children: label
772
+ })]
773
+ });
774
+ }
775
+ function formatTime(ms) {
776
+ const date = new Date(ms);
777
+ const pad = (value) => String(value).padStart(2, "0");
778
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
779
+ }
780
+ /**
781
+ * Right-sidebar body: where this Session came from in Feishu.
782
+ * @param props - the tab's Session.
783
+ * @returns the origin page.
784
+ */
785
+ function OriginTab({ sessionId }) {
786
+ const [state, reload] = useSessionOrigin(sessionId, true);
787
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
788
+ className: "dsh-channels-origin",
789
+ children: [
790
+ state.phase === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
791
+ className: "dsh-channels-origin__muted",
792
+ children: "正在读取飞书来源…"
793
+ }) : null,
794
+ state.phase === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
795
+ className: "dsh-channels-origin__error",
796
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["读取飞书来源失败:", state.message] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
797
+ type: "button",
798
+ className: "dsh-channels-origin__link",
799
+ onClick: reload,
800
+ children: "重试"
801
+ })]
802
+ }) : null,
803
+ state.phase === "ready" && state.origin === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
804
+ className: "dsh-channels-origin__muted",
805
+ children: ["这个会话不是从飞书发起的。", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
806
+ type: "button",
807
+ className: "dsh-channels-origin__link",
808
+ onClick: reload,
809
+ children: "刷新"
810
+ })]
811
+ }) : null,
812
+ state.phase === "ready" && state.origin !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OriginDetails, {
813
+ origin: state.origin,
814
+ onRefresh: reload
815
+ }) : null
816
+ ]
817
+ });
818
+ }
819
+ function OriginDetails({ origin, onRefresh }) {
820
+ const p2p = origin.chatType === "p2p";
821
+ const rows = [["来源", p2p ? "私聊" : origin.kind === "thread" ? "群聊 · 话题" : "群聊"]];
822
+ if (!p2p && origin.chatName === void 0) rows.push(["群 ID", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
823
+ className: "dsh-channels-origin__mono",
824
+ children: origin.chatId
825
+ })]);
826
+ rows.push([origin.shared ? "请求人" : "发起人", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
827
+ className: "dsh-channels-origin__who",
828
+ children: [
829
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, {
830
+ text: origin.senderName ?? origin.senderUserId,
831
+ url: origin.senderAvatarUrl,
832
+ size: 20
833
+ }),
834
+ origin.senderName === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: origin.senderName }),
835
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
836
+ className: "dsh-channels-origin__mono dsh-channels-origin__muted",
837
+ children: origin.senderUserId
838
+ })
839
+ ]
840
+ })]);
841
+ const heading = originHeading(origin);
842
+ if (origin.shared) rows.push(["说明", "日志平台分析话题,群内成员都可以继续"]);
843
+ rows.push(["开始于", formatTime(origin.createdAt)]);
844
+ rows.push(origin.bound && origin.lastActivityAt !== void 0 ? ["最近活跃", formatTime(origin.lastActivityAt)] : ["状态", "飞书侧已切换到新会话或已过期"]);
845
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
846
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
847
+ className: "dsh-channels-origin__head",
848
+ children: [heading.avatarUrl === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FeishuChannelsIcon, {
849
+ size: 16,
850
+ active: false
851
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, {
852
+ text: heading.title,
853
+ url: heading.avatarUrl,
854
+ size: 28
855
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
856
+ className: "dsh-channels-origin__title",
857
+ children: heading.title
858
+ })]
859
+ }),
860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", {
861
+ className: "dsh-channels-origin__list",
862
+ children: rows.map(([label, value]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
863
+ className: "dsh-channels-origin__row",
864
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: value })]
865
+ }, label))
866
+ }),
867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
868
+ className: "dsh-channels-origin__actions",
869
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
870
+ className: "dsh-channels-origin__button",
871
+ href: origin.chatLink,
872
+ target: "_blank",
873
+ rel: "noreferrer",
874
+ children: "在飞书中打开"
875
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
876
+ type: "button",
877
+ className: "dsh-channels-origin__link",
878
+ onClick: onRefresh,
879
+ children: "刷新"
880
+ })]
881
+ })
882
+ ] });
883
+ }
884
+ //#endregion
519
885
  //#region src/client/catalog.ts
520
886
  /**
521
887
  * 浏览器半区的设置面板选择器目录折叠:把 Host 下发的字段描述与实时目录
@@ -1452,14 +1818,6 @@ window.__ModuleLoader__.load({
1452
1818
  default: return PANEL_COPY.accessSourcePanel;
1453
1819
  }
1454
1820
  }
1455
- /** Round initial badge in front of a whitelist entry. */
1456
- function Avatar(props) {
1457
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1458
- className: "dsh-channels-cfg__avatar",
1459
- "aria-hidden": "true",
1460
- children: Array.from(props.text)[0] ?? "?"
1461
- });
1462
- }
1463
1821
  /**
1464
1822
  * One titled whitelist with remove buttons and a collapsed add form.
1465
1823
  * `onAdd` resolves true when the entry was added (the form then closes).
@@ -1484,7 +1842,10 @@ window.__ModuleLoader__.load({
1484
1842
  !loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyRow, { text: PANEL_COPY.accessNotLoaded }) : null,
1485
1843
  loaded && entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyRow, { text: empty }) : null,
1486
1844
  entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1487
- leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, { text: avatar ?? entry.name ?? entry.id }),
1845
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, {
1846
+ text: avatar ?? entry.name ?? entry.id,
1847
+ url: entry.avatarUrl
1848
+ }),
1488
1849
  label: entry.name ?? entry.id,
1489
1850
  summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1490
1851
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1554,15 +1915,20 @@ window.__ModuleLoader__.load({
1554
1915
  }
1555
1916
  /** Pending authorization requests, settled with the same resolver as the DM card. */
1556
1917
  function RequestList(props) {
1557
- const { requests, disabled, working, onResolve } = props;
1918
+ const { requests, profiles, disabled, working, onResolve } = props;
1558
1919
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Section, {
1559
1920
  title: `${PANEL_COPY.accessRequestsTitle} · ${requests.length}`,
1560
1921
  tone: "warn",
1561
1922
  children: requests.map((request) => {
1562
- const target = request.name ?? request.targetId;
1563
- const where = request.kind === "user" && (request.groupName ?? request.chatId) !== void 0 ? fillCopy(PANEL_COPY.accessRequestIn, { group: request.groupName ?? request.chatId ?? "" }) : "";
1923
+ const profile = request.kind === "user" ? profiles.user(request.targetId) : profiles.chat(request.targetId);
1924
+ const target = profile?.name ?? request.name ?? request.targetId;
1925
+ const group = request.chatId === void 0 ? request.groupName : profiles.chat(request.chatId)?.name ?? request.groupName ?? request.chatId;
1926
+ const where = request.kind === "user" && group !== void 0 ? fillCopy(PANEL_COPY.accessRequestIn, { group }) : "";
1564
1927
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1565
- leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, { text: target }),
1928
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, {
1929
+ text: target,
1930
+ url: profile?.avatarUrl
1931
+ }),
1566
1932
  label: `${target} ${where}${request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser}`,
1567
1933
  summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1568
1934
  className: "dsh-channels-cfg__mono",
@@ -1601,15 +1967,10 @@ window.__ModuleLoader__.load({
1601
1967
  * @module dsh-channels/client/panel/access-tab
1602
1968
  */
1603
1969
  function AccessTab(props) {
1604
- const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1970
+ const { form, profiles, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1605
1971
  const { access } = form.panel;
1606
1972
  const working = form.busy === "access";
1607
1973
  const disabled = form.busy !== null || form.locked || !access.loaded;
1608
- const ownerAdmin = access.mode === "guarded" && access.admin.source === "owner" ? access.admin.userId : void 0;
1609
- const fields = ownerAdmin === void 0 ? form.fields : form.fields.map((field) => field.key === "adminUserId" ? {
1610
- ...field,
1611
- summary: fillCopy(PANEL_COPY.accessAdminOwnerSummary, { id: ownerAdmin })
1612
- } : field);
1613
1974
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1614
1975
  className: "dsh-channels-cfg__tabBody",
1615
1976
  children: [
@@ -1625,24 +1986,31 @@ window.__ModuleLoader__.load({
1625
1986
  kind: "error",
1626
1987
  text: access.admin.ownerLookupError === void 0 ? PANEL_COPY.accessAdminMissingWarning : `${PANEL_COPY.accessAdminMissingWarning}(${access.admin.ownerLookupError})`
1627
1988
  }) : null,
1989
+ profiles.warning === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1990
+ className: "dsh-channels-cfg__secFoot",
1991
+ children: fillCopy(PANEL_COPY.profilesWarning, { reason: profiles.warning })
1992
+ }),
1628
1993
  access.loaded && access.requests.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestList, {
1629
1994
  requests: access.requests,
1995
+ profiles,
1630
1996
  disabled,
1631
1997
  working,
1632
1998
  onResolve: onResolveRequest
1633
1999
  }) : null,
1634
2000
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
1635
- form: {
1636
- ...form,
1637
- fields
1638
- },
1639
- groups: TAB_FIELD_GROUPS.access
2001
+ form,
2002
+ groups: TAB_FIELD_GROUPS.access,
2003
+ extra: { access: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminRow, {
2004
+ admin: access.admin,
2005
+ profiles
2006
+ }) }
1640
2007
  }),
1641
2008
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Whitelist, {
1642
2009
  title: PANEL_COPY.accessGroupsTitle,
1643
2010
  entries: access.groups.map((group) => ({
1644
2011
  id: group.chatId,
1645
- name: group.name,
2012
+ name: profiles.chat(group.chatId)?.name ?? group.name,
2013
+ avatarUrl: profiles.chat(group.chatId)?.avatarUrl,
1646
2014
  source: group.source
1647
2015
  })),
1648
2016
  avatar: "群",
@@ -1660,7 +2028,8 @@ window.__ModuleLoader__.load({
1660
2028
  title: PANEL_COPY.accessUsersTitle,
1661
2029
  entries: access.users.map((user) => ({
1662
2030
  id: user.userId,
1663
- name: user.name,
2031
+ name: profiles.user(user.userId)?.name ?? user.name,
2032
+ avatarUrl: profiles.user(user.userId)?.avatarUrl,
1664
2033
  source: user.source
1665
2034
  })),
1666
2035
  empty: PANEL_COPY.accessUsersEmpty,
@@ -1676,6 +2045,31 @@ window.__ModuleLoader__.load({
1676
2045
  ]
1677
2046
  });
1678
2047
  }
2048
+ /** The effective administrator with their Feishu name and avatar; nothing while there is none. */
2049
+ function AdminRow(props) {
2050
+ const { admin, profiles } = props;
2051
+ if (admin.userId === void 0) return null;
2052
+ const profile = profiles.user(admin.userId);
2053
+ const name = profile?.name ?? admin.userId;
2054
+ const source = admin.source === "owner" ? PANEL_COPY.accessAdminSourceOwner : PANEL_COPY.accessAdminSourceConfig;
2055
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2056
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, {
2057
+ text: name,
2058
+ url: profile?.avatarUrl
2059
+ }),
2060
+ label: name,
2061
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2062
+ PANEL_COPY.accessAdminCurrent,
2063
+ " · ",
2064
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2065
+ className: "dsh-channels-cfg__mono",
2066
+ children: admin.userId
2067
+ }),
2068
+ " · ",
2069
+ source
2070
+ ] })
2071
+ });
2072
+ }
1679
2073
  //#endregion
1680
2074
  //#region src/client/panel/credentials-card.tsx
1681
2075
  /**
@@ -2159,6 +2553,76 @@ window.__ModuleLoader__.load({
2159
2553
  });
2160
2554
  }
2161
2555
  //#endregion
2556
+ //#region src/client/panel/profiles.ts
2557
+ /**
2558
+ * Live Feishu names and avatars for every user and chat the access and wiki
2559
+ * tabs show. The page asks the Host once on entry and again whenever the set
2560
+ * of shown ids changes; a failed request keeps the previous answer.
2561
+ *
2562
+ * @module dsh-channels/client/panel/profiles
2563
+ */
2564
+ /**
2565
+ * The `user_id`s and `chat_id`s one access snapshot shows.
2566
+ * @param access - the panel's access snapshot.
2567
+ * @returns sorted, distinct ids per kind.
2568
+ */
2569
+ function profileIds(access) {
2570
+ const users = /* @__PURE__ */ new Set();
2571
+ const chats = /* @__PURE__ */ new Set();
2572
+ if (access.admin.userId !== void 0) users.add(access.admin.userId);
2573
+ for (const user of access.users) users.add(user.userId);
2574
+ for (const user of access.wikiUsers) users.add(user.userId);
2575
+ for (const group of access.groups) chats.add(group.chatId);
2576
+ for (const request of access.requests) {
2577
+ if (request.kind === "user") users.add(request.targetId);
2578
+ else chats.add(request.targetId);
2579
+ if (request.chatId !== void 0) chats.add(request.chatId);
2580
+ }
2581
+ return {
2582
+ users: [...users].sort(),
2583
+ chats: [...chats].sort()
2584
+ };
2585
+ }
2586
+ /**
2587
+ * Profiles for one access snapshot; empty until the panel has loaded.
2588
+ * @param access - the panel's access snapshot, null while loading.
2589
+ * @returns lookups by id and the latest failure reason.
2590
+ */
2591
+ function useFeishuProfiles(access) {
2592
+ const ids = (0, react.useMemo)(() => access === null ? {
2593
+ users: [],
2594
+ chats: []
2595
+ } : profileIds(access), [access]);
2596
+ const key = `${ids.users.join(",")}|${ids.chats.join(",")}`;
2597
+ const [answer, setAnswer] = (0, react.useState)({
2598
+ users: {},
2599
+ chats: {}
2600
+ });
2601
+ const [requestError, setRequestError] = (0, react.useState)(void 0);
2602
+ (0, react.useEffect)(() => {
2603
+ if (ids.users.length === 0 && ids.chats.length === 0) return;
2604
+ let current = true;
2605
+ fetchFeishuProfiles(ids.users, ids.chats).then((next) => {
2606
+ if (!current) return;
2607
+ setAnswer(next);
2608
+ setRequestError(void 0);
2609
+ }, (error) => {
2610
+ if (current) setRequestError(error instanceof Error ? error.message : String(error));
2611
+ });
2612
+ return () => {
2613
+ current = false;
2614
+ };
2615
+ }, [key]);
2616
+ return (0, react.useMemo)(() => {
2617
+ const warning = requestError ?? answer.warning;
2618
+ return {
2619
+ user: (userId) => answer.users[userId],
2620
+ chat: (chatId) => answer.chats[chatId],
2621
+ ...warning === void 0 ? {} : { warning }
2622
+ };
2623
+ }, [answer, requestError]);
2624
+ }
2625
+ //#endregion
2162
2626
  //#region src/client/panel/wiki-tab.tsx
2163
2627
  /**
2164
2628
  * 「知识库」tab:知识库工具字段(读取 / 编辑开关、可访问范围)+ 知识库
@@ -2168,7 +2632,7 @@ window.__ModuleLoader__.load({
2168
2632
  * @module dsh-channels/client/panel/wiki-tab
2169
2633
  */
2170
2634
  function WikiTab(props) {
2171
- const { form, accessNotice, accessError, onAddUser, onRemoveUser } = props;
2635
+ const { form, profiles, accessNotice, accessError, onAddUser, onRemoveUser } = props;
2172
2636
  const { access } = form.panel;
2173
2637
  const disabled = form.busy !== null || form.locked || !access.loaded;
2174
2638
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2182,6 +2646,10 @@ window.__ModuleLoader__.load({
2182
2646
  kind: "error",
2183
2647
  text: accessError
2184
2648
  }),
2649
+ profiles.warning === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2650
+ className: "dsh-channels-cfg__secFoot",
2651
+ children: fillCopy(PANEL_COPY.profilesWarning, { reason: profiles.warning })
2652
+ }),
2185
2653
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
2186
2654
  form,
2187
2655
  groups: TAB_FIELD_GROUPS.wiki
@@ -2191,7 +2659,8 @@ window.__ModuleLoader__.load({
2191
2659
  countLabel: fillCopy(PANEL_COPY.wikiUsersAdminPlus, { n: access.wikiUsers.length }),
2192
2660
  entries: access.wikiUsers.map((user) => ({
2193
2661
  id: user.userId,
2194
- name: user.name,
2662
+ name: profiles.user(user.userId)?.name ?? user.name,
2663
+ avatarUrl: profiles.user(user.userId)?.avatarUrl,
2195
2664
  source: user.source
2196
2665
  })),
2197
2666
  empty: PANEL_COPY.wikiUsersEmpty,
@@ -2211,18 +2680,32 @@ window.__ModuleLoader__.load({
2211
2680
  //#endregion
2212
2681
  //#region src/client/panel/index.tsx
2213
2682
  /**
2214
- * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
2683
+ * 左侧栏「飞书渠道」主区页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
2215
2684
  * tab 导航与底部保存条的外壳。
2216
2685
  *
2217
2686
  * 所有 Host 交互(panel / status / catalog / config / credentials / access /
2218
2687
  * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
2219
- * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
2220
- * 槽位。
2688
+ * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `main` 槽位。
2221
2689
  *
2222
2690
  * @module dsh-channels/client/panel
2223
2691
  */
2224
- /** Page shell: one panel payload, one status poll, one global config draft. */
2225
- function FeishuChannelsSection(_props) {
2692
+ /**
2693
+ * Main-column page for the `main` slot: a full-height scroll region with the
2694
+ * centered panel column. The slot passes no owner props.
2695
+ * @returns the page.
2696
+ */
2697
+ function FeishuChannelsPage() {
2698
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
2699
+ className: "dsh-channels-page",
2700
+ "aria-label": PANEL_COPY.title,
2701
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2702
+ className: "dsh-channels-page__content",
2703
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FeishuChannelsPanel, {})
2704
+ })
2705
+ });
2706
+ }
2707
+ /** Panel shell: one panel payload, one status poll, one global config draft. */
2708
+ function FeishuChannelsPanel() {
2226
2709
  const [load, setLoad] = (0, react.useState)({ phase: "loading" });
2227
2710
  const [activeTab, setActiveTab] = (0, react.useState)("connection");
2228
2711
  const [draft, setDraft] = (0, react.useState)({});
@@ -2844,6 +3327,7 @@ window.__ModuleLoader__.load({
2844
3327
  renderedFields,
2845
3328
  updateDraft
2846
3329
  ]);
3330
+ const profiles = useFeishuProfiles(panel?.access ?? null);
2847
3331
  const credentials = (0, react.useMemo)(() => ({
2848
3332
  appId: appIdInput,
2849
3333
  appSecret: appSecretInput,
@@ -2969,6 +3453,7 @@ window.__ModuleLoader__.load({
2969
3453
  }) : null,
2970
3454
  activeTab === "access" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTab, {
2971
3455
  form,
3456
+ profiles,
2972
3457
  accessNotice,
2973
3458
  accessError,
2974
3459
  onAddGroup: onAddAccessGroup,
@@ -2985,6 +3470,7 @@ window.__ModuleLoader__.load({
2985
3470
  }) : null,
2986
3471
  activeTab === "wiki" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WikiTab, {
2987
3472
  form,
3473
+ profiles,
2988
3474
  accessNotice,
2989
3475
  accessError,
2990
3476
  onAddUser: (userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded),
@@ -3023,7 +3509,7 @@ window.__ModuleLoader__.load({
3023
3509
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3024
3510
  className: "dsh-channels-cfg__headRow",
3025
3511
  children: [
3026
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
3512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
3027
3513
  className: "dsh-channels-cfg__title",
3028
3514
  children: PANEL_COPY.title
3029
3515
  }),
@@ -3131,7 +3617,7 @@ window.__ModuleLoader__.load({
3131
3617
  //#endregion
3132
3618
  //#region src/client/styles.ts
3133
3619
  /**
3134
- * Settings panel styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
3620
+ * Feishu channel page styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
3135
3621
  * fallbacks, aliased once to local `--dc-*` variables, so the page follows
3136
3622
  * the active theme.
3137
3623
  *
@@ -3139,10 +3625,13 @@ window.__ModuleLoader__.load({
3139
3625
  */
3140
3626
  const STYLE_ATTRIBUTE = "data-dsh-channels-css";
3141
3627
  const CSS = `
3628
+ .dsh-channels-page{width:100%;height:100%;min-width:0;min-height:0;overflow:auto;scrollbar-gutter:stable;background:var(--dsw-alias-bg-base,#fff)}
3629
+ .dsh-channels-page__content{box-sizing:border-box;max-width:816px;margin:0 auto;padding:18px clamp(24px,4vw,48px) 48px}
3630
+ [data-platform='darwin'] .dsh-channels-page__content{padding-top:calc(18px + var(--dsh-frame-top-clearance,0px))}
3142
3631
  .dsh-channels-cfg{--dc-fg:var(--dsw-alias-label-primary,#1f2328);--dc-fg2:var(--dsw-alias-label-secondary,#5c6169);--dc-fg3:var(--dsw-alias-label-tertiary,#8a8f98);--dc-dim:var(--dsw-alias-label-dimmed,#b0b4ba);--dc-bg:var(--dsw-alias-bg-base,#fff);--dc-line:var(--dsw-alias-border-l2,rgba(0,0,0,.12));--dc-hover:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));--dc-accent:var(--dsw-alias-state-business-primary,#0a67d3);--dc-ok:var(--dsw-alias-state-success-primary,#0a7d33);--dc-warn:var(--dsw-alias-state-warn-primary,#a15c00);--dc-bad:var(--dsw-alias-state-error-primary,#c62828);--dc-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;container-type:inline-size;display:flex;flex-direction:column;gap:14px;width:100%;max-width:720px;color:var(--dc-fg);font-size:13px;line-height:20px}
3143
3632
  .dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 6px;background:var(--dc-bg)}
3144
3633
  .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3145
- .dsh-channels-cfg__title{margin:0 2px 0 0;font-size:17px;font-weight:600;line-height:26px}
3634
+ .dsh-channels-cfg__title{margin:0 2px 0 0;font-size:20px;font-weight:500;line-height:28px}
3146
3635
  .dsh-channels-cfg__meta{color:var(--dc-fg3);font-size:12px}
3147
3636
  .dsh-channels-cfg__spacer{flex:1}
3148
3637
  .dsh-channels-cfg__mono{font-family:var(--dc-mono);font-size:12px}
@@ -3233,7 +3722,7 @@ window.__ModuleLoader__.load({
3233
3722
  .dsh-channels-cfg__scopeList li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
3234
3723
  .dsh-channels-cfg__scopeList .dsh-channels-cfg__link{font-size:12px}
3235
3724
  .dsh-channels-cfg__list>.dsh-channels-cfg__scopeList{border-top-width:0}
3236
- .dsh-channels-cfg__avatar{display:grid;place-items:center;flex:none;width:26px;height:26px;border-radius:50%;background:var(--dc-hover);color:var(--dc-fg2);font-size:12px}
3725
+ .dsh-channels-avatar{box-sizing:border-box;display:grid;place-items:center;flex:none;border-radius:50%;object-fit:cover;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#5c6169);line-height:1}
3237
3726
  .dsh-channels-cfg__addRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px}
3238
3727
  .dsh-channels-cfg__addRow .dsh-channels-cfg__input{flex:1 1 160px;width:auto}
3239
3728
  .dsh-channels-cfg__emptyRow{padding:11px 14px;color:var(--dc-fg3);font-size:12px}
@@ -3246,6 +3735,26 @@ window.__ModuleLoader__.load({
3246
3735
  .dsh-channels-cfg__stepTitle{font-weight:500}
3247
3736
  .dsh-channels-cfg__error{padding:10px 12px;border:1px solid var(--dc-bad);border-radius:10px;color:var(--dc-bad);font-size:12px;line-height:18px;display:flex;flex-direction:column;gap:8px}
3248
3737
  .dsh-channels-cfg__loading{padding:16px 0;color:var(--dc-fg3);font-size:12px}
3738
+ .dsh-channels-origin-chip{display:inline-flex;align-items:center;gap:4px;max-width:180px;height:24px;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#5c6169);font:inherit;font-size:12px;line-height:24px;cursor:pointer;white-space:nowrap}
3739
+ .dsh-channels-origin-chip:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-primary,#1f2328)}
3740
+ .dsh-channels-origin-chip>svg{flex:none}
3741
+ .dsh-channels-origin__who{display:inline-flex;align-items:center;gap:6px;vertical-align:middle}
3742
+ .dsh-channels-origin-chip__text{overflow:hidden;text-overflow:ellipsis}
3743
+ .dsh-channels-origin{box-sizing:border-box;height:100%;overflow:auto;padding:16px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
3744
+ .dsh-channels-origin__head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
3745
+ .dsh-channels-origin__title{font-size:15px;font-weight:600;line-height:22px;overflow-wrap:anywhere}
3746
+ .dsh-channels-origin__list{display:flex;flex-direction:column;margin:0;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:10px}
3747
+ .dsh-channels-origin__row{display:flex;gap:12px;padding:9px 12px}
3748
+ .dsh-channels-origin__row+.dsh-channels-origin__row{border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
3749
+ .dsh-channels-origin__row dt{flex:none;width:64px;color:var(--dsw-alias-label-tertiary,#8a8f98)}
3750
+ .dsh-channels-origin__row dd{flex:1;min-width:0;margin:0;overflow-wrap:anywhere}
3751
+ .dsh-channels-origin__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
3752
+ .dsh-channels-origin__muted{color:var(--dsw-alias-label-tertiary,#8a8f98)}
3753
+ .dsh-channels-origin__error{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-state-error-primary,#c62828);font-size:12px}
3754
+ .dsh-channels-origin__actions{display:flex;align-items:center;gap:14px;margin-top:14px}
3755
+ .dsh-channels-origin__button{display:inline-flex;align-items:center;height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:7px;color:inherit;font-size:13px;text-decoration:none}
3756
+ .dsh-channels-origin__button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
3757
+ .dsh-channels-origin__link{padding:0;border:0;background:none;color:var(--dsw-alias-state-business-primary,#0a67d3);font:inherit;font-size:13px;cursor:pointer}
3249
3758
  @media (prefers-reduced-motion:reduce){.dsh-channels-cfg__switch,.dsh-channels-cfg__switch::after{transition:none}}
3250
3759
  @container (max-width:560px){
3251
3760
  .dsh-channels-cfg__row:not(.dsh-channels-cfg__row--wide){flex-wrap:wrap}
@@ -3267,11 +3776,13 @@ window.__ModuleLoader__.load({
3267
3776
  //#endregion
3268
3777
  //#region src/client/index.ts
3269
3778
  /**
3270
- * dsh-channels client half: 设置 →「飞书渠道」分区。
3779
+ * dsh-channels client half: 左侧栏全局入口「飞书渠道」+ 主区整页设置面板,
3780
+ * 以及对话内的「飞书来源」(会话头部入口 + 右侧栏标签页)。
3271
3781
  *
3272
3782
  * 只依赖平台核心的 `slots` 服务与 react(bundle 里 react 走平台模块表,
3273
- * 其余全部内联)。页面数据来自 Host 半侧的 `/dsh-channels/*` 路由,因此
3274
- * 不需要 typert 契约,也不 import 任何 DSH 客户端包。
3783
+ * 其余全部内联);右侧栏服务存在时才注册来源标签页。页面数据来自 Host
3784
+ * 半侧的 `/dsh-channels/*` 路由,因此不需要 typert 契约,也不 import 任何
3785
+ * DSH 客户端包。
3275
3786
  *
3276
3787
  * @module dsh-channels/client
3277
3788
  */
@@ -3279,28 +3790,67 @@ window.__ModuleLoader__.load({
3279
3790
  const name = "dsh-channels";
3280
3791
  /** Required client services: the Slots registry is part of the web shell core. */
3281
3792
  const inject = ["slots"];
3282
- /** Section id and nav order (built-ins: account -10, general 0, models 10, plugins 15, agent-presets 20). */
3283
- const SETTINGS_SECTION_ID = "feishu-channels";
3284
- const SETTINGS_SECTION_ORDER = 40;
3285
3793
  /**
3286
- * Mount the settings section and its stylesheet.
3794
+ * Main panel id: the `sidebar.panellist` row id and the matching `main` slot key.
3795
+ * Must not change once released: DSH keys navigation state by it.
3796
+ */
3797
+ const PANEL_ID = "feishu-channels";
3798
+ /** Left-sidebar row order (built-ins: tasks 10; Plugins is pinned last by the sidebar). */
3799
+ const PANEL_ORDER = 40;
3800
+ /** Session header action order (after the preset label -10 and subagents 10). */
3801
+ const ORIGIN_ACTION_ORDER = 30;
3802
+ /**
3803
+ * Mount the left-sidebar entry, its main-column page, the stylesheet, and —
3804
+ * while the right sidebar is present — the Feishu origin header entry and tab.
3287
3805
  * @param ctx - client plugin context carrying the slots registry.
3288
3806
  */
3289
3807
  function apply(ctx) {
3290
- if (typeof ctx.effect === "function") ctx.effect(() => installStyles(), "dsh-channels: settings styles");
3808
+ if (typeof ctx.effect === "function") ctx.effect(() => installStyles(), "dsh-channels: panel styles");
3291
3809
  else installStyles();
3292
- ctx.slots.inject("settings.section", () => ctx.slots.register({
3293
- name: "settings.section",
3294
- id: SETTINGS_SECTION_ID,
3810
+ ctx.slots.inject("main", () => ctx.slots.register({
3811
+ name: "main",
3812
+ key: PANEL_ID
3813
+ }, FeishuChannelsPage));
3814
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
3815
+ name: "sidebar.panellist",
3816
+ id: PANEL_ID,
3295
3817
  order: 40,
3296
3818
  label: () => PANEL_COPY.title
3297
- }, FeishuChannelsSection));
3819
+ }, FeishuChannelsIcon));
3820
+ ctx.inject?.(["sidebarRightTabs", "sidebarRight"], mountOrigin);
3821
+ }
3822
+ /**
3823
+ * Register the origin tab type, its body and title, and the header entry that
3824
+ * opens it. Everything lives as long as the right-sidebar services do.
3825
+ * @param scope - child context holding the right-sidebar services.
3826
+ */
3827
+ function mountOrigin(scope) {
3828
+ const definition = {
3829
+ id: ORIGIN_TAB_ID,
3830
+ kind: ORIGIN_TAB_KIND,
3831
+ title: () => ORIGIN_TAB_TITLE
3832
+ };
3833
+ scope.effect?.(() => scope.sidebarRightTabs.register(definition), "dsh-channels: origin tab type");
3834
+ scope.slots.inject("sidebar.right.pane.tab", () => scope.slots.register({
3835
+ name: "sidebar.right.pane.tab",
3836
+ key: ORIGIN_TAB_ID
3837
+ }, OriginTab));
3838
+ scope.slots.inject("conversation.session.header.actions", () => scope.slots.register({
3839
+ name: "conversation.session.header.actions",
3840
+ id: "dsh-channels-origin",
3841
+ order: 30,
3842
+ inject: () => ({ openOrigin: () => {
3843
+ scope.sidebarRight.openTab(ORIGIN_TAB_KIND);
3844
+ } })
3845
+ }, OriginHeaderAction));
3298
3846
  }
3299
3847
  //#endregion
3300
- exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID;
3301
- exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER;
3848
+ exports.ORIGIN_ACTION_ORDER = ORIGIN_ACTION_ORDER;
3849
+ exports.PANEL_ID = PANEL_ID;
3850
+ exports.PANEL_ORDER = PANEL_ORDER;
3302
3851
  exports.apply = apply;
3303
3852
  exports.inject = inject;
3853
+ exports.mountOrigin = mountOrigin;
3304
3854
  exports.name = name;
3305
3855
  return module.exports;
3306
3856
  }