@roaming-ai/dsh-group-chat 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/lib/client.js +176 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +110 -89
- package/lib/types/client/components/SpeakerOrb.d.ts +13 -0
- package/lib/types/core/types.d.ts +3 -2
- package/lib/types/host/persistence/persistence.d.ts +2 -4
- package/lib/types/host/persistence/store.d.ts +5 -1
- package/lib/types/host/service.d.ts +2 -2
- package/lib/types/host/state.d.ts +1 -0
- package/lib/types/host/tools/tools.d.ts +2 -1
- package/lib/types/index.d.ts +3 -0
- package/package.json +4 -1
- package/src/client/GroupChatPanel.tsx +2 -0
- package/src/client/components/ChatPanel.tsx +1 -1
- package/src/client/components/Composer.tsx +2 -2
- package/src/client/components/MessageFlow.tsx +6 -2
- package/src/client/components/SpeakerOrb.tsx +201 -0
- package/src/client/lib/styles.ts +9 -2
- package/src/core/types.ts +3 -2
- package/src/host/engine/conversation.ts +1 -1
- package/src/host/persistence/persistence.ts +57 -34
- package/src/host/persistence/store.ts +5 -1
- package/src/host/service.ts +4 -4
- package/src/host/state.ts +5 -2
- package/src/host/tools/tools.ts +40 -46
- package/src/index.ts +7 -5
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-group-chat
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
DSH Web GUI 的「模型群聊」插件:多模型角色群组对话面板。
|
|
4
6
|
|
|
5
7
|
兼容:`dsh >= 0.1.5-rc.1`(Web profile)
|
|
@@ -18,7 +20,7 @@ DSH Web GUI 的「模型群聊」插件:多模型角色群组对话面板。
|
|
|
18
20
|
- **@点名**:裸 `@` 弹出成员候选(↑↓ 选择、Enter/Tab 插入);`@` 后继续键入则在群工作区内检索文件,插入 `@path` 芯片(只写入路径,不把文件正文再灌进 prompt)
|
|
19
21
|
- **三区工作台**:左栏导航(搜索 + 目录树);中栏会话流(色环头像 + 模型徽章 + 相对时间,贴底自动跟随、可回到底部);右栏上下文(角色卡 + 工作区目录)。左右栏均可经接缝钮收起,窄面板时右栏覆盖式呈现
|
|
20
22
|
- **群组工作区目录**:每个群组指定一个目录(群级、全会话共享;无独立笔记或文件清单)。角色发言时自动读取目录**根下一层**文本文件(扩展名白名单、跳过隐藏项、最多 20 个;单文件 16k / 总量 48k 截断)注入全体角色 system 提示词的「共享资料」块;支持 `~`、绝对路径与相对 DSH 工作区根解析,右栏内置目录选择浏览器;未设置则不注入、不暴露工具
|
|
21
|
-
- **工具执行(docs/TOOLS.md)**:设置工作区后角色可调用 `read_file` / `list_dir` 主动查看工作区(realpath 硬边界,逃逸/软链越界一律拒绝);**权限档位**于消息输入框下方左下角芯片切换(对齐主会话 `/permission` 三档:仅可查看 / 工作区内修改 / 完全权限;默认仅可查看,按群组记忆持久化)——工作区内修改档角色可请求执行 shell
|
|
23
|
+
- **工具执行(docs/TOOLS.md)**:设置工作区后角色可调用 `read_file` / `list_dir` 主动查看工作区(realpath 硬边界,逃逸/软链越界一律拒绝);**权限档位**于消息输入框下方左下角芯片切换(对齐主会话 `/permission` 三档:仅可查看 / 工作区内修改 / 完全权限;默认仅可查看,按群组记忆持久化)——工作区内修改档角色可请求执行 shell 命令(经 DSH `ctx.shell` 沙箱执行器:cwd 与沙箱策略均收紧到工作区、超时 120s),**每条命令在会话流内逐条确认**(允许 / 拒绝 / 停止,命令全文展示不截断);完全权限档免确认直接执行(对齐 DSH danger-full-access 语义),切换时经主会话同款风险确认弹窗;工具调用以折叠行内嵌于角色消息(含耗时与状态),摘要注入后续发言上下文;不支持 tools 的模型自动降级为纯文本
|
|
22
24
|
- **设置启用**:设置页「模型群聊」分区提供启停开关(持久化于 settings.yaml);关闭时隐藏侧边栏入口并中止进行中的对话
|
|
23
25
|
|
|
24
26
|
## 架构
|
|
@@ -112,8 +114,8 @@ dsh plugin --profile web remove @roaming-ai/dsh-group-chat
|
|
|
112
114
|
- **session-\<uuid\>.json**:名称/主题/`createdAt`/消息;可选 `namePinned` / `topicPinned`、`constraints` / `constraintsUpToSeq`(空数组与水位 0 省略)、消息上的 `toolCalls` / `error` / `failedRoleId`
|
|
113
115
|
- 工作区文件内容不写入本目录;每次发言由 materials 实时读盘。全量保留,不裁剪
|
|
114
116
|
- 事件驱动写:变更按文件标脏,同一 tick 内合并;流式 partial 不落盘。会话文件与 `roles.json` 紧凑序列化,ledger pretty
|
|
115
|
-
-
|
|
116
|
-
- 写失败保留脏标记,下次 flush 自动重试;插件卸载时先唤醒确认等待并 kill
|
|
117
|
+
- 原子写(`@deepseek-ai/dsh-atomic-write` 的 `writeFileAtomic`:wx 独占创建 + 随机后缀 tmp + rename,无 per-file fsync 对齐 DSH 基座标准;每个实际发生 rename 的目录一次 fsync)、单实例 `.lock`(PID 存活检测,崩溃残留自动清理)
|
|
118
|
+
- 写失败保留脏标记,下次 flush 自动重试;插件卸载时先唤醒确认等待并 kill 子进程,再等待挂起 flush 并最终落盘、释放锁(异步 disposer,cordis await)
|
|
117
119
|
- 损坏处理:单会话文件坏仅丢该会话消息(隔离 `.corrupt-*`,每前缀保留最近 1 份);ledger 有 id 而文件缺失则空消息重建;`roles.json` 坏仅空该群角色;ledger 坏可扫描群组目录回收(仅丢群组名);行级校验丢弃孤儿条目;hydrate 清 `.tmp-*`
|
|
118
120
|
- v1 双文件布局(ledger.json + messages.json)首次启动自动迁移(幂等;旧 messages.json 归档为 `.migrated-*`,从不删除)
|
|
119
121
|
- 锁不可得(双进程)时降级为内存态运行并告警
|
package/lib/client.js
CHANGED
|
@@ -122,6 +122,7 @@ window.__ModuleLoader__.load({
|
|
|
122
122
|
".dsgc-msg.mine{flex-direction:row-reverse}",
|
|
123
123
|
".dsgc-avatar{width:28px;height:28px;box-sizing:border-box;border:2px solid var(--dsw-alias-border-l3,rgba(128,128,128,.4));border-radius:50%;background:var(--dsw-alias-bg-module-platform,rgba(128,128,128,.12));color:var(--dsw-alias-label-primary,inherit);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex:none;margin-top:1px}",
|
|
124
124
|
".dsgc-avatar.mine{border-color:transparent;background:var(--dsw-alias-button-info-fill,#4f6ef7);color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
125
|
+
".dsgc-avatar .dsgc-orb{display:block}",
|
|
125
126
|
".dsgc-msgbody{max-width:76%;min-width:0;display:flex;flex-direction:column;gap:5px}",
|
|
126
127
|
".dsgc-msg.mine .dsgc-msgbody{align-items:flex-end}",
|
|
127
128
|
".dsgc-msghead{display:flex;gap:6px;align-items:center;min-width:0;font-size:12px;color:var(--dsw-alias-label-secondary,inherit)}",
|
|
@@ -197,8 +198,8 @@ window.__ModuleLoader__.load({
|
|
|
197
198
|
".dsgc-ph{position:absolute;left:14px;top:6px;max-width:calc(100% - 22px);color:var(--dsw-alias-label-tertiary,inherit);pointer-events:none;user-select:none;font-size:13px;line-height:24px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
198
199
|
".dsgc-chipin{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb,var(--role-color,#888) 15%,transparent);border-radius:999px;padding:1px 7px 1px 5px;margin:0 1px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;user-select:all}",
|
|
199
200
|
".dsgc-card .dsgc-sendrow{padding:0 12px 10px}",
|
|
200
|
-
".dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}",
|
|
201
|
-
".dsgc-stopbtn:hover:not(:disabled){filter:brightness(1.08);color:#fff}",
|
|
201
|
+
"button.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}",
|
|
202
|
+
"button.dsgc-stopbtn:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.08);color:#fff}",
|
|
202
203
|
".dsgc-parts{display:flex;align-items:flex-start;gap:6px;min-height:24px}",
|
|
203
204
|
".dsgc-partslabel{font-size:12px;line-height:24px;color:var(--dsw-alias-label-tertiary,inherit);flex:none}",
|
|
204
205
|
".dsgc-partlist{display:flex;flex-wrap:wrap;align-items:center;gap:6px;row-gap:4px;flex:1;min-width:0}",
|
|
@@ -2125,6 +2126,167 @@ window.__ModuleLoader__.load({
|
|
|
2125
2126
|
});
|
|
2126
2127
|
}
|
|
2127
2128
|
//#endregion
|
|
2129
|
+
//#region src/client/components/SpeakerOrb.tsx
|
|
2130
|
+
/**
|
|
2131
|
+
* 发言人执行态点阵光球(live 行头像内容):角色色单色点阵 Canvas 2D 动画。
|
|
2132
|
+
* thinking(轨道热斑游走=等待首字节/推理中)与 listening(向外涟漪=正文
|
|
2133
|
+
* 流出)两态间弹簧缩放 + 权重交叉淡入;裁掉参考实现 MatrixOrb 的 label、
|
|
2134
|
+
* level 与 idle 态(本场景只有执行中一种挂载时机),尺寸钉死为头像内容盒。
|
|
2135
|
+
* @module dsh-group-chat/client/SpeakerOrb
|
|
2136
|
+
*/
|
|
2137
|
+
const TAU = Math.PI * 2;
|
|
2138
|
+
const STATES = ["thinking", "listening"];
|
|
2139
|
+
/** 头像内容盒:28px 外框减 2px 描边 ×2;圆形轮廓由 d 截断近似。 */
|
|
2140
|
+
const SIZE = 24;
|
|
2141
|
+
const GRID = 7;
|
|
2142
|
+
/** 点阵铺开比例:外圈点贴近描边又不被 border-radius 裁切。 */
|
|
2143
|
+
const SPREAD = .86;
|
|
2144
|
+
const SCALE = {
|
|
2145
|
+
thinking: .94,
|
|
2146
|
+
listening: 1
|
|
2147
|
+
};
|
|
2148
|
+
const DAMPING = 26;
|
|
2149
|
+
const ATTACK = .22;
|
|
2150
|
+
const RELEASE = .08;
|
|
2151
|
+
/** thinking 态热斑轨道(点阵归一化坐标)。 */
|
|
2152
|
+
const ORBITERS = [
|
|
2153
|
+
{
|
|
2154
|
+
radius: .62,
|
|
2155
|
+
speed: 2.2,
|
|
2156
|
+
phase: 0,
|
|
2157
|
+
spread: .42
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
radius: .4,
|
|
2161
|
+
speed: -1.7,
|
|
2162
|
+
phase: 2.1,
|
|
2163
|
+
spread: .36
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
radius: .8,
|
|
2167
|
+
speed: 1.15,
|
|
2168
|
+
phase: 4,
|
|
2169
|
+
spread: .34
|
|
2170
|
+
}
|
|
2171
|
+
];
|
|
2172
|
+
function envelope(t) {
|
|
2173
|
+
const slow = .5 + .5 * Math.sin(t * .62 + .4);
|
|
2174
|
+
const fast = .5 + .5 * Math.sin(t * 1.9 + 1.1);
|
|
2175
|
+
return .22 + .78 * (.45 + .55 * slow) * fast;
|
|
2176
|
+
}
|
|
2177
|
+
function intensityOf(state, d, nx, ny, t, amplitude) {
|
|
2178
|
+
if (state === "listening") return .32 + amplitude * (.34 + .38 * (.5 + .5 * Math.sin(d * 4.2 - t * 3)));
|
|
2179
|
+
let heat = 0;
|
|
2180
|
+
for (const o of ORBITERS) {
|
|
2181
|
+
const a = t * o.speed + o.phase;
|
|
2182
|
+
const dx = nx - Math.cos(a) * o.radius;
|
|
2183
|
+
const dy = ny - Math.sin(a) * o.radius;
|
|
2184
|
+
heat += Math.exp(-(dx * dx + dy * dy) / (o.spread * o.spread));
|
|
2185
|
+
}
|
|
2186
|
+
return .26 + .8 * Math.min(1, heat);
|
|
2187
|
+
}
|
|
2188
|
+
function subscribeToZoom(onChange) {
|
|
2189
|
+
window.addEventListener("resize", onChange);
|
|
2190
|
+
return () => window.removeEventListener("resize", onChange);
|
|
2191
|
+
}
|
|
2192
|
+
function useDevicePixelRatio() {
|
|
2193
|
+
return (0, react.useSyncExternalStore)(subscribeToZoom, () => Math.min(window.devicePixelRatio || 1, 4), () => 1);
|
|
2194
|
+
}
|
|
2195
|
+
function SpeakerOrb(props) {
|
|
2196
|
+
const { state, color } = props;
|
|
2197
|
+
const canvasRef = (0, react.useRef)(null);
|
|
2198
|
+
const stateRef = (0, react.useRef)(state);
|
|
2199
|
+
const redrawRef = (0, react.useRef)(null);
|
|
2200
|
+
const dpr = useDevicePixelRatio();
|
|
2201
|
+
(0, react.useEffect)(() => {
|
|
2202
|
+
stateRef.current = state;
|
|
2203
|
+
}, [state]);
|
|
2204
|
+
(0, react.useEffect)(() => {
|
|
2205
|
+
const canvas = canvasRef.current;
|
|
2206
|
+
const ctx = canvas?.getContext("2d");
|
|
2207
|
+
if (!canvas || !ctx) return;
|
|
2208
|
+
const buffer = Math.round(SIZE * dpr);
|
|
2209
|
+
canvas.width = canvas.height = buffer;
|
|
2210
|
+
ctx.scale(buffer / SIZE, buffer / SIZE);
|
|
2211
|
+
ctx.fillStyle = color;
|
|
2212
|
+
const half = 3;
|
|
2213
|
+
const spacing = SIZE * SPREAD / 6;
|
|
2214
|
+
const maxRadius = spacing * .6;
|
|
2215
|
+
const center = SIZE / 2;
|
|
2216
|
+
const weights = {
|
|
2217
|
+
thinking: 0,
|
|
2218
|
+
listening: 0
|
|
2219
|
+
};
|
|
2220
|
+
weights[stateRef.current] = 1;
|
|
2221
|
+
const draw = (t, amplitude, scale) => {
|
|
2222
|
+
ctx.clearRect(0, 0, SIZE, SIZE);
|
|
2223
|
+
for (let iy = 0; iy < GRID; iy++) for (let ix = 0; ix < GRID; ix++) {
|
|
2224
|
+
const nx = (ix - half) / half;
|
|
2225
|
+
const ny = (iy - half) / half;
|
|
2226
|
+
const d = Math.hypot(nx, ny);
|
|
2227
|
+
if (d > 1.12) continue;
|
|
2228
|
+
let blended = 0;
|
|
2229
|
+
for (const s of STATES) {
|
|
2230
|
+
if (weights[s] < .001) continue;
|
|
2231
|
+
blended += weights[s] * intensityOf(s, d, nx, ny, t, amplitude);
|
|
2232
|
+
}
|
|
2233
|
+
const intensity = Math.min(1, Math.max(0, blended));
|
|
2234
|
+
const radius = maxRadius * Math.exp(-d * d * 1.7) * intensity * scale;
|
|
2235
|
+
if (radius * dpr < .5) continue;
|
|
2236
|
+
ctx.beginPath();
|
|
2237
|
+
ctx.arc(center + (ix - half) * spacing * scale, center + (iy - half) * spacing * scale, radius, 0, TAU);
|
|
2238
|
+
ctx.fill();
|
|
2239
|
+
}
|
|
2240
|
+
};
|
|
2241
|
+
if (typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
2242
|
+
redrawRef.current = () => {
|
|
2243
|
+
const current = stateRef.current;
|
|
2244
|
+
for (const s of STATES) weights[s] = s === current ? 1 : 0;
|
|
2245
|
+
draw(0, envelope(0), SCALE[current]);
|
|
2246
|
+
};
|
|
2247
|
+
redrawRef.current();
|
|
2248
|
+
return () => {
|
|
2249
|
+
redrawRef.current = null;
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
let t = 0;
|
|
2253
|
+
let amplitude = 0;
|
|
2254
|
+
let scale = SCALE[stateRef.current];
|
|
2255
|
+
let velocity = 0;
|
|
2256
|
+
let last = performance.now();
|
|
2257
|
+
let raf = 0;
|
|
2258
|
+
const frame = (now) => {
|
|
2259
|
+
const dt = Math.min((now - last) / 1e3, .05);
|
|
2260
|
+
last = now;
|
|
2261
|
+
t += dt;
|
|
2262
|
+
const current = stateRef.current;
|
|
2263
|
+
const target = envelope(t);
|
|
2264
|
+
const rate = target > amplitude ? ATTACK : RELEASE;
|
|
2265
|
+
amplitude += (target - amplitude) * (1 - Math.pow(1 - rate, dt * 60));
|
|
2266
|
+
const step = 1 - Math.pow(.84, dt * 60);
|
|
2267
|
+
for (const s of STATES) weights[s] += ((s === current ? 1 : 0) - weights[s]) * step;
|
|
2268
|
+
velocity += (-180 * (scale - SCALE[current]) - DAMPING * velocity) * dt;
|
|
2269
|
+
scale += velocity * dt;
|
|
2270
|
+
draw(t, amplitude, scale);
|
|
2271
|
+
raf = requestAnimationFrame(frame);
|
|
2272
|
+
};
|
|
2273
|
+
raf = requestAnimationFrame(frame);
|
|
2274
|
+
return () => cancelAnimationFrame(raf);
|
|
2275
|
+
}, [color, dpr]);
|
|
2276
|
+
(0, react.useEffect)(() => {
|
|
2277
|
+
redrawRef.current?.();
|
|
2278
|
+
}, [state]);
|
|
2279
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
|
|
2280
|
+
ref: canvasRef,
|
|
2281
|
+
"aria-hidden": true,
|
|
2282
|
+
className: "dsgc-orb",
|
|
2283
|
+
style: {
|
|
2284
|
+
width: SIZE,
|
|
2285
|
+
height: SIZE
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
//#endregion
|
|
2128
2290
|
//#region src/client/components/MessageFlow.tsx
|
|
2129
2291
|
function MessageFlow(props) {
|
|
2130
2292
|
const { snap, sess, busyNow, msgById, action, onRetrySpeak } = props;
|
|
@@ -2133,15 +2295,20 @@ window.__ModuleLoader__.load({
|
|
|
2133
2295
|
const replaceMsg = replaceId ? msgById[replaceId] : null;
|
|
2134
2296
|
const liveRoleId = snap.run.currentRoleId || replaceMsg && (replaceMsg.failedRoleId || replaceMsg.speaker) || null;
|
|
2135
2297
|
const lr = busyNow && liveRoleId ? roleById(snap, liveRoleId) : null;
|
|
2298
|
+
const liveColor = lr ? lr.color || "#888" : "#888";
|
|
2299
|
+
const orbState = snap.run.partial ? "listening" : "thinking";
|
|
2136
2300
|
const live = lr ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2137
2301
|
className: "dsgc-msg live",
|
|
2138
2302
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2139
2303
|
className: "dsgc-avatar",
|
|
2140
2304
|
style: {
|
|
2141
|
-
border: "2px solid " +
|
|
2142
|
-
"--role-color":
|
|
2305
|
+
border: "2px solid " + liveColor,
|
|
2306
|
+
"--role-color": liveColor
|
|
2143
2307
|
},
|
|
2144
|
-
children:
|
|
2308
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpeakerOrb, {
|
|
2309
|
+
state: orbState,
|
|
2310
|
+
color: liveColor
|
|
2311
|
+
})
|
|
2145
2312
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2146
2313
|
className: "dsgc-msgbody",
|
|
2147
2314
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -2615,7 +2782,7 @@ window.__ModuleLoader__.load({
|
|
|
2615
2782
|
!input.trim() ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2616
2783
|
className: "dsgc-ph",
|
|
2617
2784
|
"aria-hidden": "true",
|
|
2618
|
-
children: "发消息给全群,@成员
|
|
2785
|
+
children: "发消息给全群,@成员 点名让其回应…"
|
|
2619
2786
|
}) : null,
|
|
2620
2787
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2621
2788
|
className: "dsgc-edit",
|
|
@@ -2698,7 +2865,7 @@ window.__ModuleLoader__.load({
|
|
|
2698
2865
|
onClick: () => {
|
|
2699
2866
|
sendMsg();
|
|
2700
2867
|
},
|
|
2701
|
-
disabled: !participants.length && !mentionedRoles.length || !sess,
|
|
2868
|
+
disabled: !input.trim() || !participants.length && !mentionedRoles.length || !sess,
|
|
2702
2869
|
children: [Icon(_deepseek_ai_dsh_client_ui_primitives.IconSendOutline16, 16), "发送"]
|
|
2703
2870
|
})
|
|
2704
2871
|
]
|
|
@@ -2801,7 +2968,7 @@ window.__ModuleLoader__.load({
|
|
|
2801
2968
|
}),
|
|
2802
2969
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2803
2970
|
className: "dsgc-hint",
|
|
2804
|
-
children: enabledRoles.length ? "发送消息开始讨论;@成员
|
|
2971
|
+
children: enabledRoles.length ? "发送消息开始讨论;@成员 点名让其优先回应——每轮全体参与角色按顺序各发言一次,可用右下角轮数控制(1–10 轮)" : "先在右侧添加角色(每个角色可绑定不同模型),再回到这里发起讨论。"
|
|
2805
2972
|
})
|
|
2806
2973
|
]
|
|
2807
2974
|
})
|
|
@@ -3528,6 +3695,7 @@ window.__ModuleLoader__.load({
|
|
|
3528
3695
|
const { insertChip, insertFileChip } = useMentionChip(inputRef, setMention, setMentionIdx, syncFromDOM);
|
|
3529
3696
|
const sendMsg = (0, react.useCallback)(async () => {
|
|
3530
3697
|
if (busyNow || !sess || sendingRef.current) return;
|
|
3698
|
+
if (!input.trim()) return;
|
|
3531
3699
|
sendingRef.current = true;
|
|
3532
3700
|
try {
|
|
3533
3701
|
if (!participants.length && !mentionedRoles.length) {
|