@sidleo3/dsh-chat 0.0.4
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/client/bot-list.js +243 -0
- package/client/bot-settings.js +175 -0
- package/client/bot-shared-settings.js +561 -0
- package/client/chat-ui.js +134 -0
- package/client/context-enhancement.js +435 -0
- package/client/delivery-targets.js +334 -0
- package/client/diagnostics.js +160 -0
- package/client/i18n.js +371 -0
- package/client/index.js +77 -0
- package/client/list-order.js +144 -0
- package/client/rpc.js +52 -0
- package/client/scoped-mode-editor.js +111 -0
- package/client/section.js +250 -0
- package/client/session-badges.js +263 -0
- package/client/styles.js +960 -0
- package/client/version-panel.js +97 -0
- package/cordis.patch.yml +5 -0
- package/host/bot-model.mjs +53 -0
- package/host/bot-settings.mjs +247 -0
- package/host/channel-registry.mjs +237 -0
- package/host/commands.mjs +857 -0
- package/host/deferred.mjs +291 -0
- package/host/delivery.mjs +377 -0
- package/host/file-log.mjs +169 -0
- package/host/guidance.mjs +73 -0
- package/host/index.mjs +7 -0
- package/host/interactions.mjs +330 -0
- package/host/json-store.mjs +144 -0
- package/host/log-tail.mjs +63 -0
- package/host/panel.mjs +1012 -0
- package/host/paths.mjs +50 -0
- package/host/plugin.mjs +873 -0
- package/host/prompt-context.mjs +70 -0
- package/host/rpc.mjs +147 -0
- package/host/session-keys.mjs +25 -0
- package/host/session-store.mjs +187 -0
- package/host/sessions.mjs +1348 -0
- package/host/tools.mjs +283 -0
- package/lib/client.js +4431 -0
- package/lib/index.js +5676 -0
- package/package.json +63 -0
- package/shared/access-policy.mjs +263 -0
- package/shared/channel-rail.mjs +156 -0
- package/shared/context-enhancement.mjs +415 -0
- package/shared/contract.mjs +120 -0
- package/shared/panel-sections.mjs +76 -0
- package/shared/reply-reference.mjs +115 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用的"两作用域 × 多选项"设置块。
|
|
3
|
+
*
|
|
4
|
+
* 飞书的任务过程展示(私聊/群聊各自 off / 实时过程卡 / 逐步直播)就是这一形态;
|
|
5
|
+
* 其他渠道以后要加同类设置直接复用,不必各写一份。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-chat/client/scoped-mode-editor
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
const h = React.createElement;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 两作用域模式选择器。
|
|
16
|
+
*
|
|
17
|
+
* **选完即存**:没有「保存」按钮——卡片里只有两个下拉,按钮挂在右上角还常驻灰色,
|
|
18
|
+
* 第一眼既不知道它保存什么,也不知道改哪里才能点亮它。失败则回滚到外部真值并说明原因。
|
|
19
|
+
*
|
|
20
|
+
* @param props - {
|
|
21
|
+
* title, description, scopes: [{ key, label }], options: [{ value, label, help }],
|
|
22
|
+
* value: { [scopeKey]: optionValue }, disabled, saving, error, translate, onSave,
|
|
23
|
+
* }。
|
|
24
|
+
* @returns React 元素。
|
|
25
|
+
*/
|
|
26
|
+
export function ScopedModeEditor({
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
scopes,
|
|
30
|
+
options,
|
|
31
|
+
value = {},
|
|
32
|
+
disabled = false,
|
|
33
|
+
saving = false,
|
|
34
|
+
error = null,
|
|
35
|
+
translate,
|
|
36
|
+
onSave,
|
|
37
|
+
}) {
|
|
38
|
+
const t = typeof translate === 'function' ? translate : (key) => key;
|
|
39
|
+
const [draft, setDraft] = React.useState(() => ({ ...value }));
|
|
40
|
+
const [pending, setPending] = React.useState(null);
|
|
41
|
+
const [failed, setFailed] = React.useState(null);
|
|
42
|
+
/**
|
|
43
|
+
* 帮助文案只显示"正在操作的那个作用域"的:两个作用域共用同一组选项,
|
|
44
|
+
* 各贴一遍就是同一句话重复两次(截图里就是这么重复的)。
|
|
45
|
+
*/
|
|
46
|
+
const [helpFor, setHelpFor] = React.useState(scopes[0]?.key ?? null);
|
|
47
|
+
|
|
48
|
+
const same = (a, b) => scopes.every((scope) => (a[scope.key] ?? null) === (b[scope.key] ?? null));
|
|
49
|
+
const locked = disabled || saving || pending !== null;
|
|
50
|
+
|
|
51
|
+
// 外部值变化(例如重新读取)时同步草稿;已经在草稿上的值不重建对象,避免多余重渲染。
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (pending !== null) return;
|
|
54
|
+
setDraft((current) => (same(current, value) ? current : { ...value }));
|
|
55
|
+
// same/scopes 每次渲染都是新引用,这里只以 value/pending 为触发条件。
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
}, [value, pending]);
|
|
58
|
+
|
|
59
|
+
const choose = async (scopeKey, nextValue) => {
|
|
60
|
+
if (locked) return;
|
|
61
|
+
const next = { ...draft, [scopeKey]: nextValue };
|
|
62
|
+
setDraft(next); // 乐观:下拉立刻反映选择
|
|
63
|
+
setFailed(null);
|
|
64
|
+
setPending(scopeKey);
|
|
65
|
+
try {
|
|
66
|
+
await onSave({ ...next });
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
setDraft({ ...value }); // 失败回滚到外部真值,并把原因留在卡片上
|
|
69
|
+
setFailed(cause?.message ?? String(cause));
|
|
70
|
+
} finally {
|
|
71
|
+
setPending(null);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const savingHint = pending !== null
|
|
76
|
+
? h('span', { className: 'dchat-status' }, t('保存中…'))
|
|
77
|
+
: null;
|
|
78
|
+
const help = options.find((option) => option.value === (draft[helpFor] ?? options[0]?.value))?.help;
|
|
79
|
+
// 选项文案由调用方给**键**,这里翻译:渠道不必各自准备两份文案。
|
|
80
|
+
|
|
81
|
+
return h('section', { className: 'dchat-card' },
|
|
82
|
+
h('div', { className: 'dchat-cardHeader' },
|
|
83
|
+
h('div', { className: 'dchat-cardHeading' },
|
|
84
|
+
h('h3', { className: 'dchat-cardTitle' }, title),
|
|
85
|
+
description ? h('p', { className: 'dchat-cardDescription' }, description) : null),
|
|
86
|
+
savingHint ? h('div', { className: 'dchat-actions' }, savingHint) : null),
|
|
87
|
+
h('div', { className: 'dchat-scopeGrid' }, scopes.map((scope) => {
|
|
88
|
+
const selected = draft[scope.key] ?? options[0]?.value;
|
|
89
|
+
const selectId = `dchat-mode-${scope.key}`;
|
|
90
|
+
return h('div', { key: scope.key, className: 'dchat-scopeRow' },
|
|
91
|
+
h('label', { className: 'dchat-scopeLabel', htmlFor: selectId }, scope.label),
|
|
92
|
+
h('select', {
|
|
93
|
+
id: selectId,
|
|
94
|
+
className: 'dchat-select',
|
|
95
|
+
value: selected,
|
|
96
|
+
disabled: locked,
|
|
97
|
+
'aria-label': `${title} · ${scope.label}`,
|
|
98
|
+
onFocus: () => setHelpFor(scope.key),
|
|
99
|
+
onChange: (event) => {
|
|
100
|
+
setHelpFor(scope.key);
|
|
101
|
+
void choose(scope.key, event.target.value);
|
|
102
|
+
},
|
|
103
|
+
}, options.map((option) => h('option', {
|
|
104
|
+
key: option.value, value: option.value,
|
|
105
|
+
}, t(option.label))),
|
|
106
|
+
scope.key === helpFor && help
|
|
107
|
+
? h('p', { className: 'dchat-cardDescription' }, t(help))
|
|
108
|
+
: null));
|
|
109
|
+
})),
|
|
110
|
+
failed || error ? h('p', { className: 'dchat-error', role: 'alert' }, failed ?? error) : null);
|
|
111
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页「Chat机器人」入口:一个 hub 入口 + 由已安装渠道插件动态生成的左栏。
|
|
3
|
+
*
|
|
4
|
+
* 渠道页由各渠道包注册到 hub 声明的子槽 `chat.channel.page`(keyed,key = 渠道 id)。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-chat/client/section
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
import { channelIconUri } from '../shared/channel-rail.mjs';
|
|
12
|
+
import { CHANNEL_PAGE_SLOT } from '../shared/contract.mjs';
|
|
13
|
+
import { BotList } from './bot-list.js';
|
|
14
|
+
import { DiagnosticsPanel } from './diagnostics.js';
|
|
15
|
+
import { CHANNEL_ORDER_KEY, orderedItems, useListOrder } from './list-order.js';
|
|
16
|
+
import { VersionPanel } from './version-panel.js';
|
|
17
|
+
|
|
18
|
+
const h = React.createElement;
|
|
19
|
+
|
|
20
|
+
/** 未安装任何渠道插件时给出的安装提示。 */
|
|
21
|
+
const KNOWN_CHANNEL_PACKAGES = Object.freeze([
|
|
22
|
+
'dsh-chat-feishu',
|
|
23
|
+
'dsh-chat-weixin',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function ChannelMark({ entry }) {
|
|
27
|
+
// 渠道图标(和侧边栏会话行徽标同一份);没有图标才退回首字母。
|
|
28
|
+
const iconUri = channelIconUri(entry.icon);
|
|
29
|
+
if (iconUri) {
|
|
30
|
+
return h('span', {
|
|
31
|
+
// 有真图标就不套那个"字母块"的边框与底色,让它看起来就是应用图标。
|
|
32
|
+
className: 'dchat-channelMark dchat-channelMarkIcon',
|
|
33
|
+
'aria-hidden': 'true',
|
|
34
|
+
}, h('img', { src: iconUri, alt: '', width: 20, height: 20 }));
|
|
35
|
+
}
|
|
36
|
+
if (typeof entry.logo === 'function') {
|
|
37
|
+
return h('span', { className: 'dchat-channelMark', 'aria-hidden': 'true' }, h(entry.logo));
|
|
38
|
+
}
|
|
39
|
+
const initial = entry.id.slice(0, 1).toUpperCase();
|
|
40
|
+
return h('span', { className: 'dchat-channelMark', 'aria-hidden': 'true' }, initial);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 设置页 section 组件。props 由 slot 框架注入:`t` / `renderSlot` 与注册时的 `inject`。
|
|
45
|
+
*
|
|
46
|
+
* @param props - { channels, chatUi, translate, t, renderSlot }。
|
|
47
|
+
* @returns React 元素。
|
|
48
|
+
*/
|
|
49
|
+
export function ChatSettingsSection(props) {
|
|
50
|
+
const { channels, chatUi, translate, t: frameworkT, renderSlot, connection } = props;
|
|
51
|
+
/** 版本与更新默认收起:右上角入口按需展开(只在展开时读一次数据)。 */
|
|
52
|
+
const [showVersions, setShowVersions] = React.useState(false);
|
|
53
|
+
/** 诊断同理:不看时不请求(读日志要碰文件系统,没必要常驻)。 */
|
|
54
|
+
const [showDiagnostics, setShowDiagnostics] = React.useState(false);
|
|
55
|
+
/**
|
|
56
|
+
* 右栏两级视图:`{ kind: 'bots' }` 机器人列表 → `{ kind: 'channel', botId }` 机器人设置页。
|
|
57
|
+
* 和 dsh-im 同构:渠道 → 机器人 → 设置。
|
|
58
|
+
*/
|
|
59
|
+
const [view, setView] = React.useState({ kind: 'bots', botId: null });
|
|
60
|
+
const t = typeof translate === 'function' ? translate
|
|
61
|
+
: (typeof frameworkT === 'function' ? frameworkT : (key) => key);
|
|
62
|
+
|
|
63
|
+
const entries = React.useSyncExternalStore(
|
|
64
|
+
(onChange) => channels.subscribe(onChange),
|
|
65
|
+
() => channels.getSnapshot(),
|
|
66
|
+
() => channels.getSnapshot(),
|
|
67
|
+
);
|
|
68
|
+
/**
|
|
69
|
+
* 左栏渠道顺序:用户拖动过就按用户顺序排,没排过的(新装的渠道)排在后面。
|
|
70
|
+
*
|
|
71
|
+
* 纯显示偏好(存浏览器),所以拖动只影响这台浏览器看到的顺序——渠道注册时的 `order`
|
|
72
|
+
* 仍然是所有机器的默认顺序。
|
|
73
|
+
*/
|
|
74
|
+
const channelOrder = useListOrder(CHANNEL_ORDER_KEY, (entry) => entry?.id);
|
|
75
|
+
const orderedEntries = React.useMemo(
|
|
76
|
+
() => orderedItems(entries, channelOrder.order, (entry) => entry?.id),
|
|
77
|
+
[entries, channelOrder.order],
|
|
78
|
+
);
|
|
79
|
+
/**
|
|
80
|
+
* 正在拖动的渠道:**用 ref 记住"拖的是谁"**,state 只负责高亮。
|
|
81
|
+
*
|
|
82
|
+
* 一开始只用 state,结果同一个任务里连着派发 dragstart/drop 时(React 把几次 setState
|
|
83
|
+
* 批到一起,drop 的闭包里读到的还是 null)顺序不会变——真实浏览器里两次事件分属不同任务,
|
|
84
|
+
* 所以只在自动化里暴露。用 ref 更稳:互不依赖渲染时机。
|
|
85
|
+
*/
|
|
86
|
+
const dragChannelRef = React.useRef(null);
|
|
87
|
+
const [dragChannel, setDragChannel] = React.useState(null);
|
|
88
|
+
const [dropChannel, setDropChannel] = React.useState(null);
|
|
89
|
+
|
|
90
|
+
const [selected, setSelected] = React.useState(null);
|
|
91
|
+
/**
|
|
92
|
+
* 当前选中的渠道:用户点过就用他点的那个,否则用**排在最前面的那个**。
|
|
93
|
+
*
|
|
94
|
+
* 这里必须用 `orderedEntries`:用注册顺序的 `entries[0]` 时,用户把顺序调成
|
|
95
|
+
* 「飞书在前、微信在后」,一进设置页却仍然默认打开微信(真机反馈)——
|
|
96
|
+
* 默认项当然应该跟他自己排的第一项一致。
|
|
97
|
+
*/
|
|
98
|
+
const activeId = entries.some((entry) => entry.id === selected)
|
|
99
|
+
? selected
|
|
100
|
+
: (orderedEntries[0]?.id ?? null);
|
|
101
|
+
|
|
102
|
+
// 切换渠道时回到"机器人列表",否则会带着上一个渠道的 botId 进错页。
|
|
103
|
+
const activeEntry = entries.find((entry) => entry.id === activeId) ?? null;
|
|
104
|
+
const openSettings = (botId) => setView({ kind: 'channel', botId });
|
|
105
|
+
const backToBots = () => setView({ kind: 'bots', botId: null });
|
|
106
|
+
|
|
107
|
+
const EmptyState = chatUi?.components?.EmptyState;
|
|
108
|
+
|
|
109
|
+
/** 一级视图:机器人列表。 */
|
|
110
|
+
function botListView() {
|
|
111
|
+
if (!activeEntry) return null;
|
|
112
|
+
return h(BotList, {
|
|
113
|
+
key: activeEntry.id,
|
|
114
|
+
channelId: activeEntry.id,
|
|
115
|
+
label: activeEntry.label,
|
|
116
|
+
// 渠道能力说明(如「仅私聊」)放右栏标题下:左栏只留"图标 + 渠道名",形态才整齐。
|
|
117
|
+
note: activeEntry.capabilities?.note ?? null,
|
|
118
|
+
// 渠道级设置入口:`setup.label` 有才显示(飞书没有渠道级表单,微信是「扫码接入」)。
|
|
119
|
+
setup: activeEntry.capabilities?.setup ?? null,
|
|
120
|
+
connection,
|
|
121
|
+
chatUi,
|
|
122
|
+
translate: t,
|
|
123
|
+
onOpenSettings: openSettings,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 二级视图:机器人(或整个渠道)的设置页(返回条由外层给)。 */
|
|
128
|
+
function channelView() {
|
|
129
|
+
return h(React.Fragment, null,
|
|
130
|
+
typeof renderSlot === 'function'
|
|
131
|
+
? renderSlot(
|
|
132
|
+
CHANNEL_PAGE_SLOT,
|
|
133
|
+
{ channelId: activeId, botId: view.botId },
|
|
134
|
+
{ entryKey: activeId },
|
|
135
|
+
)
|
|
136
|
+
: h('p', { className: 'dchat-cardDescription' }, t('当前页面不支持渠道子槽。')));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 二级视图的返回条。 */
|
|
140
|
+
function backBar() {
|
|
141
|
+
return h('div', { className: 'dchat-panelBar' },
|
|
142
|
+
h('button', {
|
|
143
|
+
type: 'button', className: 'dchat-button', onClick: backToBots,
|
|
144
|
+
}, t('← 机器人列表')));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let body = null;
|
|
148
|
+
if (entries.length === 0) {
|
|
149
|
+
body = EmptyState
|
|
150
|
+
? h(EmptyState, {
|
|
151
|
+
title: t('未安装任何聊天软件插件'),
|
|
152
|
+
description: t('安装渠道插件后,这里会出现对应的聊天软件。'),
|
|
153
|
+
}, h('ul', { className: 'dchat-list' },
|
|
154
|
+
h('li', { className: 'dchat-listItem' }, t('已知渠道插件')),
|
|
155
|
+
...KNOWN_CHANNEL_PACKAGES.map((name) => h('li', {
|
|
156
|
+
key: name, className: 'dchat-listItem',
|
|
157
|
+
}, h('code', { className: 'dchat-code' }, `dsh plugin --profile web add ${name}`)))))
|
|
158
|
+
: null;
|
|
159
|
+
} else if (view.kind !== 'bots') {
|
|
160
|
+
// 进了机器人设置:不再保留左栏渠道列表——这时它没用,还占掉一半宽度。
|
|
161
|
+
body = h('div', { className: 'dchat-solo' }, backBar(), channelView());
|
|
162
|
+
} else {
|
|
163
|
+
body = h('div', { className: 'dchat-layout' },
|
|
164
|
+
h('nav', { className: 'dchat-rail', role: 'tablist', 'aria-label': t('渠道导航') },
|
|
165
|
+
orderedEntries.map((entry) => h('button', {
|
|
166
|
+
key: entry.id,
|
|
167
|
+
type: 'button',
|
|
168
|
+
role: 'tab',
|
|
169
|
+
id: `dchat-tab-${entry.id}`,
|
|
170
|
+
className: `dchat-channel${dropChannel === entry.id && dragChannel !== entry.id ? ' dchat-dropTarget' : ''}`,
|
|
171
|
+
'aria-selected': entry.id === activeId,
|
|
172
|
+
'aria-controls': `dchat-panel-${entry.id}`,
|
|
173
|
+
onClick: () => {
|
|
174
|
+
setSelected(entry.id);
|
|
175
|
+
backToBots();
|
|
176
|
+
},
|
|
177
|
+
// 拖动排序:把手是那个 grip(button 自己 draggable 在部分浏览器上不灵,
|
|
178
|
+
// 而且整行可拖会与"点一下切换渠道"抢手势)。
|
|
179
|
+
onDragOver: (event) => {
|
|
180
|
+
const from = dragChannelRef.current;
|
|
181
|
+
if (!from || from === entry.id) return;
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
setDropChannel(entry.id);
|
|
184
|
+
},
|
|
185
|
+
onDrop: (event) => {
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
const from = dragChannelRef.current;
|
|
188
|
+
if (from && from !== entry.id) channelOrder.move(orderedEntries, from, entry.id);
|
|
189
|
+
dragChannelRef.current = null;
|
|
190
|
+
setDropChannel(null);
|
|
191
|
+
setDragChannel(null);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
h('span', {
|
|
195
|
+
className: 'dchat-grip',
|
|
196
|
+
draggable: true,
|
|
197
|
+
title: t('拖动可调整顺序'),
|
|
198
|
+
'aria-hidden': 'true',
|
|
199
|
+
onDragStart: (event) => {
|
|
200
|
+
dragChannelRef.current = entry.id;
|
|
201
|
+
setDragChannel(entry.id);
|
|
202
|
+
// Firefox 不设 dataTransfer 就不会开始拖。
|
|
203
|
+
event.dataTransfer?.setData('text/plain', entry.id);
|
|
204
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
205
|
+
},
|
|
206
|
+
onDragEnd: () => {
|
|
207
|
+
dragChannelRef.current = null;
|
|
208
|
+
setDragChannel(null);
|
|
209
|
+
setDropChannel(null);
|
|
210
|
+
},
|
|
211
|
+
}, '⋮⋮'),
|
|
212
|
+
h(ChannelMark, { entry }),
|
|
213
|
+
h('span', { className: 'dchat-channelLabel' },
|
|
214
|
+
h('strong', null, entry.label()))))),
|
|
215
|
+
h('main', {
|
|
216
|
+
className: 'dchat-panel',
|
|
217
|
+
role: 'tabpanel',
|
|
218
|
+
id: `dchat-panel-${activeId}`,
|
|
219
|
+
'aria-labelledby': `dchat-tab-${activeId}`,
|
|
220
|
+
}, botListView()));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return h('section', { className: 'dchat-page', 'aria-label': t('Chat机器人设置') },
|
|
224
|
+
h('header', { className: 'dchat-header' },
|
|
225
|
+
h('div', { className: 'dchat-brand' },
|
|
226
|
+
h('strong', { className: 'dchat-brandName' }, 'DSH-Chat'),
|
|
227
|
+
h('span', { className: 'dchat-brandHint' }, t('Chat机器人'))),
|
|
228
|
+
// 右上角入口:版本与更新 / 诊断(展开后是同一块面板,收起时不请求数据)。
|
|
229
|
+
// 用文字链接形态,避免和 DSH 自己的实心按钮(打开配置文件)平级抢注意力。
|
|
230
|
+
h('div', { className: 'dchat-headerActions' },
|
|
231
|
+
h('button', {
|
|
232
|
+
type: 'button',
|
|
233
|
+
className: 'dchat-button dchat-buttonLink',
|
|
234
|
+
'aria-expanded': showDiagnostics,
|
|
235
|
+
onClick: () => setShowDiagnostics((open) => !open),
|
|
236
|
+
}, showDiagnostics ? t('收起诊断') : t('诊断')),
|
|
237
|
+
h('button', {
|
|
238
|
+
type: 'button',
|
|
239
|
+
className: 'dchat-button dchat-buttonLink',
|
|
240
|
+
'aria-expanded': showVersions,
|
|
241
|
+
onClick: () => setShowVersions((open) => !open),
|
|
242
|
+
}, showVersions ? t('收起版本与更新') : t('版本与更新')))),
|
|
243
|
+
showDiagnostics
|
|
244
|
+
? h(DiagnosticsPanel, { connection, chatUi, translate: t })
|
|
245
|
+
: null,
|
|
246
|
+
showVersions
|
|
247
|
+
? h(VersionPanel, { connection, chatUi, translate: t })
|
|
248
|
+
: null,
|
|
249
|
+
body);
|
|
250
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边栏会话行的**渠道徽标**:把「飞书 · 标题」里的渠道名前缀换成一枚小图标。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要这样:DSH 的会话列表没有可注册的插槽(`sidebar.workspaces` 是整块替换,
|
|
5
|
+
* 换掉就把搜索/分组/对话框全盖了),所以只能像上游 dsh-im 那样做**纯装饰性**的
|
|
6
|
+
* DOM 增强——保留标题里的文字前缀(它既是匹配依据,也是降级形态),只给标题元素
|
|
7
|
+
* 加两个自有的 data 属性,再用一张样式表把前缀隐藏、画上徽标。
|
|
8
|
+
*
|
|
9
|
+
* 三条纪律:
|
|
10
|
+
* 1. **只加属性,不动 React 的文本/子节点/类名**;卸载时逐项还原;
|
|
11
|
+
* 2. **认结构不认类名**:在行内找"文本以「<渠道名> · 」开头的叶子元素",
|
|
12
|
+
* 不依赖产品的 CSS 类名,产品改类名也不会失效;
|
|
13
|
+
* 3. **图片没加载成功就不替换**(CSP 或字体缺失时保持文字前缀),绝不出现空白行。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-chat/client/session-badges
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { channelIconUri } from '../shared/channel-rail.mjs';
|
|
19
|
+
|
|
20
|
+
const STYLE_ID = 'dsh-chat-session-badges';
|
|
21
|
+
const CHANNEL_ATTR = 'data-dsh-chat-channel';
|
|
22
|
+
const TITLE_ATTR = 'data-dsh-chat-title';
|
|
23
|
+
const ROW_SELECTOR = '[role="treeitem"][aria-selected]';
|
|
24
|
+
|
|
25
|
+
/** 一枚圆角徽标的 data URI(品牌色底 + 白色字)。 */
|
|
26
|
+
export function badgeUri({ text, color }, size = 16) {
|
|
27
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `
|
|
28
|
+
+ `viewBox="0 0 ${size} ${size}"><rect x="0.5" y="0.5" width="${size - 1}" `
|
|
29
|
+
+ `height="${size - 1}" rx="4" fill="${color}"/>`
|
|
30
|
+
+ `<text x="${size / 2}" y="${size / 2 + 3.4}" text-anchor="middle" `
|
|
31
|
+
+ `font-family="-apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif" `
|
|
32
|
+
+ `font-size="9.5" font-weight="600" fill="#ffffff">${text}</text></svg>`;
|
|
33
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stylesheet(uris) {
|
|
37
|
+
const mapping = Object.entries(uris)
|
|
38
|
+
.map(([channel, uri]) => `[${CHANNEL_ATTR}="${channel}"] { --dchat-session-badge: url("${uri}"); }`)
|
|
39
|
+
.join('\n');
|
|
40
|
+
return `
|
|
41
|
+
[${CHANNEL_ATTR}][${TITLE_ATTR}] {
|
|
42
|
+
position: relative;
|
|
43
|
+
-webkit-text-fill-color: transparent;
|
|
44
|
+
text-overflow: clip !important;
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
}
|
|
47
|
+
[${CHANNEL_ATTR}][${TITLE_ATTR}]::before {
|
|
48
|
+
content: "";
|
|
49
|
+
position: absolute;
|
|
50
|
+
inset-inline-start: 0;
|
|
51
|
+
top: 50%;
|
|
52
|
+
transform: translateY(-50%);
|
|
53
|
+
width: 16px;
|
|
54
|
+
height: 16px;
|
|
55
|
+
background: var(--dchat-session-badge) center / contain no-repeat;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
}
|
|
58
|
+
[${CHANNEL_ATTR}][${TITLE_ATTR}]::after {
|
|
59
|
+
content: attr(${TITLE_ATTR}) / "";
|
|
60
|
+
position: absolute;
|
|
61
|
+
inset: 0;
|
|
62
|
+
inset-inline-start: 22px;
|
|
63
|
+
-webkit-text-fill-color: currentColor;
|
|
64
|
+
text-indent: 0;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
text-overflow: ellipsis;
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
}
|
|
70
|
+
${mapping}
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 在一个会话行里找"被打了渠道前缀的标题元素"。
|
|
76
|
+
*
|
|
77
|
+
* 只看**叶子元素**、只按文本前缀匹配——不依赖产品的 CSS 类名,产品改样式也不会失效;
|
|
78
|
+
* 同一行里多个匹配时取最靠后的那个(即最深的标题,不是外层容器)。
|
|
79
|
+
*
|
|
80
|
+
* @param row - 会话行元素(任何提供 `querySelectorAll` 的对象)。
|
|
81
|
+
* @param known - `[[渠道 id, 渠道显示名]]`,只包含徽标就绪的渠道。
|
|
82
|
+
* @returns `{ element, channel, text }` 或 null;`text` 是去掉前缀后的标题。
|
|
83
|
+
*/
|
|
84
|
+
export function findChannelTitle(row, known) {
|
|
85
|
+
if (!row || typeof row.querySelectorAll !== 'function' || known.length === 0) return null;
|
|
86
|
+
let found = null;
|
|
87
|
+
for (const element of row.querySelectorAll('*')) {
|
|
88
|
+
if (element.children?.length > 0) continue;
|
|
89
|
+
const text = element.textContent;
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
for (const [channel, label] of known) {
|
|
92
|
+
if (text.startsWith(`${label} · `)) {
|
|
93
|
+
found = { element, channel, text: text.slice(label.length + 3).trim() };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 安装会话行徽标。
|
|
102
|
+
*
|
|
103
|
+
* @param options - { channels, doc, win }。
|
|
104
|
+
* `channels` 是渠道 rail(提供 `subscribe`/`getSnapshot`)。
|
|
105
|
+
* @returns 卸载函数:断开观察、移除样式、还原所有属性。
|
|
106
|
+
*/
|
|
107
|
+
export function installSessionBadges({
|
|
108
|
+
channels, doc = globalThis.document, win = globalThis.window,
|
|
109
|
+
} = {}) {
|
|
110
|
+
if (!doc?.body || typeof win?.MutationObserver !== 'function') return () => {};
|
|
111
|
+
/** 渠道 id → { label, uri }(只放已声明 `sessionBadge` 的渠道)。 */
|
|
112
|
+
const badges = new Map();
|
|
113
|
+
/** 图片加载成功的渠道(没加载成功就保留文字前缀)。 */
|
|
114
|
+
const ready = new Set();
|
|
115
|
+
/** 被我们改过的元素 → 原始属性值(卸载时还原)。 */
|
|
116
|
+
const owned = new Map();
|
|
117
|
+
const queued = new Set();
|
|
118
|
+
const images = [];
|
|
119
|
+
let closed = false;
|
|
120
|
+
let scheduled = false;
|
|
121
|
+
|
|
122
|
+
const style = doc.createElement('style');
|
|
123
|
+
style.id = STYLE_ID;
|
|
124
|
+
doc.head?.appendChild(style);
|
|
125
|
+
|
|
126
|
+
function labels() {
|
|
127
|
+
return [...badges.values()].filter((entry) => ready.has(entry.channel))
|
|
128
|
+
.map((entry) => [entry.channel, entry.label]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 行内最深的一个"文本以「渠道名 · 」开头"的叶子元素。 */
|
|
132
|
+
function titleOf(row) {
|
|
133
|
+
return findChannelTitle(row, labels());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function restore(element) {
|
|
137
|
+
const previous = owned.get(element);
|
|
138
|
+
if (!previous) return;
|
|
139
|
+
owned.delete(element);
|
|
140
|
+
for (const [attribute, value] of previous) {
|
|
141
|
+
if (value === null) element.removeAttribute(attribute);
|
|
142
|
+
else element.setAttribute(attribute, value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function update(row) {
|
|
147
|
+
const found = row.isConnected ? titleOf(row) : null;
|
|
148
|
+
for (const marked of row.querySelectorAll(`[${CHANNEL_ATTR}]`)) {
|
|
149
|
+
if (marked !== found?.element) restore(marked);
|
|
150
|
+
}
|
|
151
|
+
if (!found) return;
|
|
152
|
+
if (!owned.has(found.element)) {
|
|
153
|
+
owned.set(found.element, [CHANNEL_ATTR, TITLE_ATTR]
|
|
154
|
+
.map((attribute) => [attribute, found.element.getAttribute(attribute)]));
|
|
155
|
+
}
|
|
156
|
+
// React 仍然持有它原来的文本节点与监听器;我们只驱动自己那两个属性的视觉替换。
|
|
157
|
+
found.element.setAttribute(CHANNEL_ATTR, found.channel);
|
|
158
|
+
found.element.setAttribute(TITLE_ATTR, found.text);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function schedule() {
|
|
162
|
+
if (closed || scheduled || queued.size === 0) return;
|
|
163
|
+
scheduled = true;
|
|
164
|
+
win.queueMicrotask(() => {
|
|
165
|
+
scheduled = false;
|
|
166
|
+
if (closed) return;
|
|
167
|
+
const rows = [...queued];
|
|
168
|
+
queued.clear();
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
try {
|
|
171
|
+
update(row);
|
|
172
|
+
} catch {
|
|
173
|
+
for (const marked of row.querySelectorAll(`[${CHANNEL_ATTR}]`)) restore(marked);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function collect(node, descendants = false) {
|
|
180
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
181
|
+
if (!element) return;
|
|
182
|
+
const row = element.closest(ROW_SELECTOR);
|
|
183
|
+
if (row) queued.add(row);
|
|
184
|
+
if (descendants) for (const child of element.querySelectorAll(ROW_SELECTOR)) queued.add(child);
|
|
185
|
+
if (owned.has(element)) {
|
|
186
|
+
const owner = element.closest(ROW_SELECTOR);
|
|
187
|
+
if (owner) queued.add(owner);
|
|
188
|
+
else restore(element);
|
|
189
|
+
}
|
|
190
|
+
schedule();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyBadges() {
|
|
194
|
+
if (closed) return;
|
|
195
|
+
badges.clear();
|
|
196
|
+
for (const entry of channels?.getSnapshot?.() ?? []) {
|
|
197
|
+
const badge = entry.sessionBadge;
|
|
198
|
+
if (!badge || typeof badge.text !== 'string' || !badge.text) continue;
|
|
199
|
+
// rail 里的 label 是**函数**(支持动态改名),必须调用后再用。
|
|
200
|
+
const label = typeof entry.label === 'function' ? entry.label() : entry.label;
|
|
201
|
+
badges.set(entry.id, {
|
|
202
|
+
channel: entry.id,
|
|
203
|
+
label: String(label ?? entry.id),
|
|
204
|
+
// 优先用渠道自己的图标(和设置页左栏同一份);没给图标才退回字徽标。
|
|
205
|
+
uri: channelIconUri(entry.icon)
|
|
206
|
+
?? badgeUri({ text: badge.text, color: badge.color ?? '#3370ff' }),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
style.textContent = stylesheet(Object.fromEntries(
|
|
210
|
+
[...badges].map(([id, badge]) => [id, badge.uri]),
|
|
211
|
+
));
|
|
212
|
+
// 图片没加载成功就不替换:宁可留着「飞书 · 」文字,也不要空一块。
|
|
213
|
+
for (const [id, badge] of badges) {
|
|
214
|
+
if (ready.has(id)) continue;
|
|
215
|
+
const image = new win.Image();
|
|
216
|
+
images.push(image);
|
|
217
|
+
image.onload = () => {
|
|
218
|
+
ready.add(id);
|
|
219
|
+
for (const row of doc.querySelectorAll(ROW_SELECTOR)) queued.add(row);
|
|
220
|
+
schedule();
|
|
221
|
+
};
|
|
222
|
+
image.src = badge.uri;
|
|
223
|
+
}
|
|
224
|
+
for (const row of doc.querySelectorAll(ROW_SELECTOR)) queued.add(row);
|
|
225
|
+
schedule();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const observer = new win.MutationObserver((records) => {
|
|
229
|
+
if (closed) return;
|
|
230
|
+
for (const record of records) {
|
|
231
|
+
collect(record.target, record.type === 'attributes');
|
|
232
|
+
if (record.type !== 'childList') continue;
|
|
233
|
+
for (const node of record.addedNodes) collect(node, true);
|
|
234
|
+
for (const node of record.removedNodes) {
|
|
235
|
+
if (node.nodeType !== 1 || node.isConnected) continue;
|
|
236
|
+
if (owned.has(node)) restore(node);
|
|
237
|
+
for (const marked of node.querySelectorAll(`[${CHANNEL_ATTR}]`)) restore(marked);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
observer.observe(doc.body, {
|
|
242
|
+
subtree: true,
|
|
243
|
+
childList: true,
|
|
244
|
+
characterData: true,
|
|
245
|
+
attributes: true,
|
|
246
|
+
attributeFilter: ['class', 'role', 'aria-selected'],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const unsubscribe = channels?.subscribe?.(applyBadges);
|
|
250
|
+
applyBadges();
|
|
251
|
+
|
|
252
|
+
return () => {
|
|
253
|
+
closed = true;
|
|
254
|
+
observer.disconnect();
|
|
255
|
+
unsubscribe?.();
|
|
256
|
+
for (const image of images) {
|
|
257
|
+
image.onload = null;
|
|
258
|
+
image.src = '';
|
|
259
|
+
}
|
|
260
|
+
for (const element of [...owned.keys()]) restore(element);
|
|
261
|
+
style.remove();
|
|
262
|
+
};
|
|
263
|
+
}
|