|
@@ -7,9 +7,12 @@ import {
|
|
|
effect,
|
|
effect,
|
|
|
inject,
|
|
inject,
|
|
|
signal,
|
|
signal,
|
|
|
|
|
+ untracked,
|
|
|
viewChild,
|
|
viewChild,
|
|
|
} from '@angular/core';
|
|
} from '@angular/core';
|
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
|
|
|
+import { toSignal } from '@angular/core/rxjs-interop';
|
|
|
|
|
+import { map } from 'rxjs/operators';
|
|
|
import { Agent, Message } from '../../../core/api/models';
|
|
import { Agent, Message } from '../../../core/api/models';
|
|
|
import { ChatService } from '../../../core/chat.service';
|
|
import { ChatService } from '../../../core/chat.service';
|
|
|
import { ChatSessionService } from '../../../core/chat-session.service';
|
|
import { ChatSessionService } from '../../../core/chat-session.service';
|
|
@@ -17,6 +20,8 @@ import { GroupChatService } from '../../../core/group-chat.service';
|
|
|
import { LiveService } from '../../../core/live.service';
|
|
import { LiveService } from '../../../core/live.service';
|
|
|
import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
|
|
import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
|
|
|
import { GroupStoreService } from '../../../core/group-store.service';
|
|
import { GroupStoreService } from '../../../core/group-store.service';
|
|
|
|
|
+import { GroupMatrixService } from '../../../core/group-matrix.service';
|
|
|
|
|
+import { GroupSendService } from '../../../core/group-send.service';
|
|
|
import { GroupBlock, blockDuration, buildGroupTurns } from '../../../core/group-turns';
|
|
import { GroupBlock, blockDuration, buildGroupTurns } from '../../../core/group-turns';
|
|
|
import { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
|
|
import { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
|
|
|
import {
|
|
import {
|
|
@@ -115,9 +120,27 @@ type Tab = 'turn' | 'members';
|
|
|
</div>
|
|
</div>
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- <div class="m-chat-body" #scroller>
|
|
|
|
|
|
|
+ @if (matrix.warningOf(session()?.id ?? ''); as warn) {
|
|
|
|
|
+ <div class="m-hist-warn" role="status">{{ warn }}</div>
|
|
|
|
|
+ }
|
|
|
|
|
+ <div class="m-chat-body" #scroller (scroll)="onScroll()">
|
|
|
|
|
+ <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
|
|
|
|
|
+ @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
|
|
|
|
|
+ <div class="m-hist-skel" aria-busy="true">
|
|
|
|
|
+ @for (i of [1, 2, 3]; track i) {
|
|
|
|
|
+ <div class="m-sk-line"></div>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
@for (t of turns(); track t.id) {
|
|
@for (t of turns(); track t.id) {
|
|
|
- <div class="m-msg-me">{{ t.userMessage.text }}</div>
|
|
|
|
|
|
|
+ <!--
|
|
|
|
|
+ 用户消息复用 app-m-msg(2026-11-07):与单聊同一套渲染,
|
|
|
|
|
+ 有附件时显示文件卡而不是裸文本。
|
|
|
|
|
+ -->
|
|
|
|
|
+ <app-m-msg [message]="t.userMessage" />
|
|
|
|
|
+ @for (f of t.userFiles ?? []; track f.id) {
|
|
|
|
|
+ <app-m-msg [message]="f" />
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
<article class="m-turn">
|
|
<article class="m-turn">
|
|
|
<!-- 过程折叠:执行中自动展开,完成后收起(Q25-B) -->
|
|
<!-- 过程折叠:执行中自动展开,完成后收起(Q25-B) -->
|
|
@@ -204,13 +227,40 @@ type Tab = 'turn' | 'members';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
<div class="m-composer-area">
|
|
<div class="m-composer-area">
|
|
|
|
|
+ <!-- 待发附件条:与移动端单聊同一形态 -->
|
|
|
|
|
+ @if (sender.pendingFiles().length > 0) {
|
|
|
|
|
+ <div class="m-pending" role="list" aria-label="待发送附件">
|
|
|
|
|
+ @for (pf of sender.pendingFiles(); track pf.id) {
|
|
|
|
|
+ <span class="m-pending-chip" role="listitem">
|
|
|
|
|
+ @if (pf.previewUrl) {
|
|
|
|
|
+ <img [src]="pf.previewUrl" [alt]="pf.file.name" class="m-pending-img" />
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
|
|
|
|
|
+ }
|
|
|
|
|
+ <span class="ellipsis">{{ pf.file.name }}</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="m-pending-x"
|
|
|
|
|
+ [attr.aria-label]="'移除 ' + pf.file.name"
|
|
|
|
|
+ (click)="sender.unstageFile(pf.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
<app-m-dock
|
|
<app-m-dock
|
|
|
[draft]="draft()"
|
|
[draft]="draft()"
|
|
|
- [sending]="sending()"
|
|
|
|
|
|
|
+ [sending]="sender.sending()"
|
|
|
|
|
+ [uploading]="sender.uploading()"
|
|
|
|
|
+ [pct]="sender.uploadPct()"
|
|
|
|
|
+ [hasAttachments]="sender.pendingFiles().length > 0"
|
|
|
[target]="'群聊'"
|
|
[target]="'群聊'"
|
|
|
placeholder="发消息,@ 需要响应的容器…"
|
|
placeholder="发消息,@ 需要响应的容器…"
|
|
|
(draftChange)="onDraft($event)"
|
|
(draftChange)="onDraft($event)"
|
|
|
(send)="send($event)"
|
|
(send)="send($event)"
|
|
|
|
|
+ (attach)="sender.stageFiles($event)"
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
}
|
|
}
|
|
@@ -290,16 +340,63 @@ type Tab = 'turn' | 'members';
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
gap: 2px;
|
|
gap: 2px;
|
|
|
}
|
|
}
|
|
|
- .m-msg-me {
|
|
|
|
|
- align-self: flex-end;
|
|
|
|
|
- max-width: 82%;
|
|
|
|
|
- padding: var(--sp-3) var(--sp-4);
|
|
|
|
|
- border: 1px solid var(--edge);
|
|
|
|
|
- border-radius: var(--r-lg) var(--r-sm) var(--r-lg) var(--r-lg);
|
|
|
|
|
|
|
+ /* 用户消息由 app-m-msg 渲染(2026-11-07),沿用其自身的己方/对方排布 */
|
|
|
|
|
+ /* 历史未完整载入的轻提示 */
|
|
|
|
|
+ .m-hist-warn {
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+ margin: var(--sp-2) var(--mob-gutter) 0;
|
|
|
|
|
+ padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: var(--warning-subtle);
|
|
|
|
|
+ color: var(--warning);
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 历史重建中的骨架 */
|
|
|
|
|
+ .m-hist-skel {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-3);
|
|
|
|
|
+ padding: var(--sp-4) var(--mob-gutter);
|
|
|
|
|
+ }
|
|
|
|
|
+ .m-sk-line {
|
|
|
|
|
+ height: 48px;
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
background: var(--surface-2);
|
|
background: var(--surface-2);
|
|
|
- color: var(--ink);
|
|
|
|
|
- white-space: pre-wrap;
|
|
|
|
|
- word-break: break-word;
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 待发附件条 */
|
|
|
|
|
+ .m-pending {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ padding: var(--sp-2) var(--mob-gutter) 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .m-pending-chip {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ max-width: 60vw;
|
|
|
|
|
+ padding: var(--sp-1) var(--sp-2);
|
|
|
|
|
+ border: 1px solid var(--edge);
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .m-pending-img {
|
|
|
|
|
+ width: 28px;
|
|
|
|
|
+ height: 28px;
|
|
|
|
|
+ border-radius: var(--r-sm);
|
|
|
|
|
+ object-fit: cover;
|
|
|
|
|
+ }
|
|
|
|
|
+ .m-pending-x {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ place-items: center;
|
|
|
|
|
+ width: 20px;
|
|
|
|
|
+ height: 20px;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ border-radius: var(--r-tag);
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ color: var(--faint);
|
|
|
|
|
+ font-size: var(--fs-11);
|
|
|
}
|
|
}
|
|
|
/* 回合:一次提问的所有输出合成一条(Q12) */
|
|
/* 回合:一次提问的所有输出合成一条(Q12) */
|
|
|
.m-turn {
|
|
.m-turn {
|
|
@@ -489,12 +586,15 @@ export class MbGroupChatPage implements OnDestroy {
|
|
|
protected readonly chat = inject(ChatService);
|
|
protected readonly chat = inject(ChatService);
|
|
|
private readonly groupLive = inject(GroupLiveService);
|
|
private readonly groupLive = inject(GroupLiveService);
|
|
|
protected readonly store = inject(GroupStoreService);
|
|
protected readonly store = inject(GroupStoreService);
|
|
|
|
|
+ /** 群聊历史 Matrix 重建(2026-11-07) */
|
|
|
|
|
+ protected readonly matrix = inject(GroupMatrixService);
|
|
|
|
|
+ /** 群聊发送编排(含附件) */
|
|
|
|
|
+ protected readonly sender = inject(GroupSendService);
|
|
|
|
|
|
|
|
private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
|
|
private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
|
|
|
|
|
|
|
|
protected readonly tab = signal<Tab>('turn');
|
|
protected readonly tab = signal<Tab>('turn');
|
|
|
protected readonly draft = signal('');
|
|
protected readonly draft = signal('');
|
|
|
- protected readonly sending = signal(false);
|
|
|
|
|
/** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
|
|
/** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
|
|
|
protected readonly sendError = signal<string | null>(null);
|
|
protected readonly sendError = signal<string | null>(null);
|
|
|
/** 每个回合的展开状态(覆盖默认的「执行中展开」) */
|
|
/** 每个回合的展开状态(覆盖默认的「执行中展开」) */
|
|
@@ -502,10 +602,18 @@ export class MbGroupChatPage implements OnDestroy {
|
|
|
/** 时钟:驱动「静默超时」判定,每秒走一格 */
|
|
/** 时钟:驱动「静默超时」判定,每秒走一格 */
|
|
|
private readonly now = signal(Date.now());
|
|
private readonly now = signal(Date.now());
|
|
|
|
|
|
|
|
- protected readonly session = computed(() => {
|
|
|
|
|
- const id = this.route.snapshot.paramMap.get('groupId') ?? '';
|
|
|
|
|
- return this.sessions.byId(id);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 当前群 id —— 订阅路由参数流(2026-11-07 与桌面端对齐)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原来读 `route.snapshot`:从群 A 点到群 B 时组件被复用、构造函数不重跑,
|
|
|
|
|
+ * 标题/成员/消息全部停在上一个群。
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly groupId = toSignal(
|
|
|
|
|
+ this.route.paramMap.pipe(map((p) => p.get('groupId') ?? '')),
|
|
|
|
|
+ { initialValue: '' },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ protected readonly session = computed(() => this.sessions.byId(this.groupId()));
|
|
|
protected readonly members = computed<Agent[]>(() => {
|
|
protected readonly members = computed<Agent[]>(() => {
|
|
|
const s = this.session();
|
|
const s = this.session();
|
|
|
return s ? this.groups.membersOf(s) : [];
|
|
return s ? this.groups.membersOf(s) : [];
|
|
@@ -593,30 +701,63 @@ export class MbGroupChatPage implements OnDestroy {
|
|
|
* 深链接直接进群聊页时本地缓存可能是空的 → `session()` 为 undefined → 页面空。
|
|
* 深链接直接进群聊页时本地缓存可能是空的 → `session()` 为 undefined → 页面空。
|
|
|
*/
|
|
*/
|
|
|
void this.loadGroupSession();
|
|
void this.loadGroupSession();
|
|
|
- // 订阅群成员的实时消息流(按成员房间,各自独立)
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 订阅群成员的实时消息流(按成员房间,各自独立)。
|
|
|
|
|
+ * 切群(2026-11-07):先释放上一个群,再订阅新群 —— 否则两个群的回复会串。
|
|
|
|
|
+ */
|
|
|
|
|
+ let prevGroup: string | null = null;
|
|
|
effect(() => {
|
|
effect(() => {
|
|
|
const s = this.session();
|
|
const s = this.session();
|
|
|
- if (s) void this.groupLive.subscribe(s);
|
|
|
|
|
|
|
+ if (!s) return;
|
|
|
|
|
+ if (prevGroup && prevGroup !== s.id) {
|
|
|
|
|
+ this.groupLive.release(prevGroup);
|
|
|
|
|
+ this.sender.clear();
|
|
|
|
|
+ }
|
|
|
|
|
+ prevGroup = s.id;
|
|
|
|
|
+ // 先从 Matrix 重建历史(权威),不 await —— 页面先渲染本地缓存
|
|
|
|
|
+ untracked(() => void this.matrix.refresh(s));
|
|
|
|
|
+ void this.groupLive.subscribe(s);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 消息变化时滚到底
|
|
|
|
|
|
|
+ // 贴底:与桌面端同一套(两帧 + 120ms 兜底;用户上翻时不拽回)
|
|
|
effect(() => {
|
|
effect(() => {
|
|
|
this.messages();
|
|
this.messages();
|
|
|
- queueMicrotask(() => {
|
|
|
|
|
- const el = this.scroller()?.nativeElement;
|
|
|
|
|
- if (el) el.scrollTop = el.scrollHeight;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ this.turns();
|
|
|
|
|
+ if (this.userScrolledAway) return;
|
|
|
|
|
+ this.stickToBottom();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 用户是否主动上翻(>160px 视为在看历史) */
|
|
|
|
|
+ private userScrolledAway = false;
|
|
|
|
|
+
|
|
|
|
|
+ protected onScroll(): void {
|
|
|
|
|
+ const el = this.scroller()?.nativeElement;
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ this.userScrolledAway = el.scrollHeight - el.scrollTop - el.clientHeight > 160;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private stickToBottom(): void {
|
|
|
|
|
+ const toBottom = (): void => {
|
|
|
|
|
+ const el = this.scroller()?.nativeElement;
|
|
|
|
|
+ if (el) el.scrollTop = el.scrollHeight;
|
|
|
|
|
+ };
|
|
|
|
|
+ toBottom();
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ toBottom();
|
|
|
|
|
+ setTimeout(toBottom, 120);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
ngOnDestroy(): void {
|
|
ngOnDestroy(): void {
|
|
|
const s = this.session();
|
|
const s = this.session();
|
|
|
if (s) this.groupLive.release(s.id);
|
|
if (s) this.groupLive.release(s.id);
|
|
|
|
|
+ this.sender.clear();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 本地没有该群会话时,用候选 agentId 去云端补拉一次 */
|
|
/** 本地没有该群会话时,用候选 agentId 去云端补拉一次 */
|
|
|
private async loadGroupSession(): Promise<void> {
|
|
private async loadGroupSession(): Promise<void> {
|
|
|
- const id = this.route.snapshot.paramMap.get('groupId') ?? '';
|
|
|
|
|
|
|
+ const id = this.groupId();
|
|
|
if (!id) return;
|
|
if (!id) return;
|
|
|
// 等容器列表就绪,才知道该按哪些 agentId 去取
|
|
// 等容器列表就绪,才知道该按哪些 agentId 去取
|
|
|
await this.live.ensureLoaded();
|
|
await this.live.ensureLoaded();
|
|
@@ -690,83 +831,11 @@ export class MbGroupChatPage implements OnDestroy {
|
|
|
|
|
|
|
|
protected async send(text: string): Promise<void> {
|
|
protected async send(text: string): Promise<void> {
|
|
|
const s = this.session();
|
|
const s = this.session();
|
|
|
- if (!s || this.sending()) return;
|
|
|
|
|
-
|
|
|
|
|
- // 🔴 先校验再发:不 @ 人 / @ 了非成员一律拦下(2026-11-05 用户裁决)
|
|
|
|
|
- const plan = this.groups.plan(text, s);
|
|
|
|
|
- if (!plan.ok) {
|
|
|
|
|
- this.sendError.set(
|
|
|
|
|
- plan.reason === 'invalid-mention'
|
|
|
|
|
- ? `这些名字不在群里:${plan.invalidNames.join('、')}`
|
|
|
|
|
- : plan.reason === 'no-mention'
|
|
|
|
|
- ? '请 @ 要响应的容器'
|
|
|
|
|
- : '请在消息里说明要做什么',
|
|
|
|
|
- );
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (!s || this.sender.sending()) return;
|
|
|
this.sendError.set(null);
|
|
this.sendError.set(null);
|
|
|
-
|
|
|
|
|
- this.sending.set(true);
|
|
|
|
|
- try {
|
|
|
|
|
- // 用户消息先入群聊存储:它是回合锚点,后续回复都挂在它下面
|
|
|
|
|
- const turnId = this.store.appendMine(s.id, text.trim(), plan.targets);
|
|
|
|
|
- // 发送时刻 = 补读的时间下限(详见 GroupLiveService.catchUp)
|
|
|
|
|
- const sentAt = Date.now();
|
|
|
|
|
-
|
|
|
|
|
- /*
|
|
|
|
|
- * 🔴 **串行**发送,不能用 `Promise.allSettled` 并发(2026-11-05)。
|
|
|
|
|
- *
|
|
|
|
|
- * `sendToAgent` 内部会 `connectSession(该容器的 provision token)`,
|
|
|
|
|
- * 而 `connectSession` 在 token 不同时会 `disconnect()` 重建客户端 ——
|
|
|
|
|
- * 并发调用两个容器时,第二个会把第一个的连接拆掉,
|
|
|
|
|
- * 导致先发的那个容器**收不到回复**(实测:消息发出去了,但没有回包)。
|
|
|
|
|
- *
|
|
|
|
|
- * 串行虽然慢一点,但稳定。多个容器的 provision 通常共用同一用户 token,
|
|
|
|
|
- * 第二次会命中幂等短路,不会真的重建。
|
|
|
|
|
- */
|
|
|
|
|
- const failed: string[] = [];
|
|
|
|
|
- const anchors: Record<string, DispatchAnchor> = {};
|
|
|
|
|
- for (const id of plan.targets) {
|
|
|
|
|
- const r = await this.chat.sendToAgent(id, plan.body, {
|
|
|
|
|
- groupId: s.id,
|
|
|
|
|
- turnId,
|
|
|
|
|
- targetAgentId: id,
|
|
|
|
|
- });
|
|
|
|
|
- if (!r.ok) {
|
|
|
|
|
- failed.push(this.memberName(id));
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- anchors[id] = { turnId, baselineEventId: r.baselineEventId, sentAt };
|
|
|
|
|
- this.chat.noteDispatchAnchor(r.roomId ?? '', r.eventId);
|
|
|
|
|
- }
|
|
|
|
|
- this.groupLive.markSent(plan.targets, anchors);
|
|
|
|
|
- // 发送失败的成员不该再挂着「等你回复」,否则 90 秒后显示「无响应」误导
|
|
|
|
|
- if (failed.length > 0) {
|
|
|
|
|
- this.groupLive.dropAwaiting(
|
|
|
|
|
- plan.targets.filter((id) => failed.includes(this.memberName(id))),
|
|
|
|
|
- );
|
|
|
|
|
- this.sendError.set(`已发送,但 @${failed.join('、@')} 未送达,请稍后重试`);
|
|
|
|
|
- }
|
|
|
|
|
- /*
|
|
|
|
|
- * 🔴 发送后**重新订阅**(2026-11-05,诊断确认必需)。
|
|
|
|
|
- *
|
|
|
|
|
- * `sendToAgent` 内部对每个容器调 `connectSession`,不同 device token 会
|
|
|
|
|
- * `disconnect()` 重建 Matrix 客户端 —— 旧的 `onMessage` 订阅绑在已销毁的
|
|
|
|
|
- * 实例上,从此收不到任何消息。
|
|
|
|
|
- * 诊断实测:一次群聊发送触发 2 次 REBUILD、894 条事件进来但订阅全失效。
|
|
|
|
|
- */
|
|
|
|
|
- const cur = this.session();
|
|
|
|
|
- if (cur) await this.groupLive.resubscribe(cur);
|
|
|
|
|
- this.draft.set('');
|
|
|
|
|
- /*
|
|
|
|
|
- * 重订阅后从 timeline 补读水位之后的回复。
|
|
|
|
|
- * 🔴 不 await:最长 60 秒,阻塞会让调用方以为「还没发出去」。
|
|
|
|
|
- */
|
|
|
|
|
- const after = this.session();
|
|
|
|
|
- if (after) void this.groupLive.catchUp(after, plan.targets, turnId, anchors).catch(() => {});
|
|
|
|
|
- } finally {
|
|
|
|
|
- this.sending.set(false);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const r = await this.sender.send(s, text, (id) => this.memberName(id));
|
|
|
|
|
+ if (r.error) this.sendError.set(r.error);
|
|
|
|
|
+ if (r.ok) this.draft.set('');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
protected pick(c: { id: string; name: string }): void {
|
|
protected pick(c: { id: string; name: string }): void {
|