|
@@ -24,7 +24,7 @@ import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../c
|
|
|
import { ChatService } from '../../../core/chat.service';
|
|
import { ChatService } from '../../../core/chat.service';
|
|
|
import { PENDING_DRAFT_KEY } from '../home/home';
|
|
import { PENDING_DRAFT_KEY } from '../home/home';
|
|
|
import { LoopGuardService } from '../../../core/loop-guard.service';
|
|
import { LoopGuardService } from '../../../core/loop-guard.service';
|
|
|
-import { Deliverable, Task } from '../../../core/api/models';
|
|
|
|
|
|
|
+import { Agent, Deliverable, SemanticTone, Task } from '../../../core/api/models';
|
|
|
import { ChatSessionService } from '../../../core/chat-session.service';
|
|
import { ChatSessionService } from '../../../core/chat-session.service';
|
|
|
import { TasksService } from '../../../core/tasks.service';
|
|
import { TasksService } from '../../../core/tasks.service';
|
|
|
import { fmtSize } from '../../../core/format';
|
|
import { fmtSize } from '../../../core/format';
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
} from '../../../core/message-segments';
|
|
} from '../../../core/message-segments';
|
|
|
import { Message } from '../../../core/api/models';
|
|
import { Message } from '../../../core/api/models';
|
|
|
import { sessionPresence } from '../../../core/agent-liveness';
|
|
import { sessionPresence } from '../../../core/agent-liveness';
|
|
|
|
|
+import { TaskPulseService } from '../../../core/task-pulse.service';
|
|
|
|
|
+import { UiMToastService } from '../../../ui/mobile/components/toast/toast';
|
|
|
import { fileIdentity } from '../../../core/s3-link';
|
|
import { fileIdentity } from '../../../core/s3-link';
|
|
|
import { mergeConsecutiveBodies } from '../../../core/message-merge';
|
|
import { mergeConsecutiveBodies } from '../../../core/message-merge';
|
|
|
import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
|
|
import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
|
|
@@ -133,9 +135,11 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
|
|
|
<app-m-drawer
|
|
<app-m-drawer
|
|
|
[open]="drawerOpen()"
|
|
[open]="drawerOpen()"
|
|
|
[activeSessionId]="chat.sessionId()"
|
|
[activeSessionId]="chat.sessionId()"
|
|
|
|
|
+ [quickNewAgentId]="chat.agent()?.id ?? null"
|
|
|
(close)="drawerOpen.set(false)"
|
|
(close)="drawerOpen.set(false)"
|
|
|
(newChat)="drawerOpen.set(false); back()"
|
|
(newChat)="drawerOpen.set(false); back()"
|
|
|
(newChatWithWs)="drawerOpen.set(false); back()"
|
|
(newChatWithWs)="drawerOpen.set(false); back()"
|
|
|
|
|
+ (quickNew)="newChatInCurrent($event)"
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
@if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
|
|
@if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
|
|
@@ -311,6 +315,7 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+
|
|
|
<!-- 全屏预览层(2026-11-03):点会话内文件/成果都走这里,不再新开标签页 -->
|
|
<!-- 全屏预览层(2026-11-03):点会话内文件/成果都走这里,不再新开标签页 -->
|
|
|
<app-image-lightbox
|
|
<app-image-lightbox
|
|
|
[open]="!!lightbox()"
|
|
[open]="!!lightbox()"
|
|
@@ -903,6 +908,14 @@ export class MbChatPage implements OnDestroy {
|
|
|
protected readonly fileBinding = inject(AgentFileBindingService);
|
|
protected readonly fileBinding = inject(AgentFileBindingService);
|
|
|
protected readonly chat = inject(ChatService);
|
|
protected readonly chat = inject(ChatService);
|
|
|
protected readonly loop = inject(LoopGuardService);
|
|
protected readonly loop = inject(LoopGuardService);
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 任务脉冲(2026-11-14 接入)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 桌面端聊天顶栏早就用 `pulse.describe()` 显示「正在处理…」,
|
|
|
|
|
+ * 手机端却只有「在线 / 正在回复」——用户在手机上根本看不出容器到底在不在干活。
|
|
|
|
|
+ * 这里补齐,与桌面端同一判据、同一文案来源。
|
|
|
|
|
+ */
|
|
|
|
|
+ protected readonly pulse = inject(TaskPulseService);
|
|
|
|
|
|
|
|
protected readonly sheetOpen = signal(false);
|
|
protected readonly sheetOpen = signal(false);
|
|
|
/** 图片走灯箱 */
|
|
/** 图片走灯箱 */
|
|
@@ -1039,8 +1052,25 @@ export class MbChatPage implements OnDestroy {
|
|
|
* 条件消失(收到回复)后自动撤掉,避免残留。
|
|
* 条件消失(收到回复)后自动撤掉,避免残留。
|
|
|
*/
|
|
*/
|
|
|
private readonly alert = inject(AlertService);
|
|
private readonly alert = inject(AlertService);
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 轻提示(2026-11-14)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 底座 `UiMToastService`:2 秒自动消失的普通提示 —— 「任务完成」正好属于
|
|
|
|
|
+ * alert.service 注释里划给它的那一类(不该用常驻的告警条,那会一直杵在屏上)。
|
|
|
|
|
+ * 容器已由 `app.ts` 全局挂载 `UiMToast`,直接注入即可。
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly toast = inject(UiMToastService);
|
|
|
private static readonly TIMEOUT_TITLE = '容器长时间未响应';
|
|
private static readonly TIMEOUT_TITLE = '容器长时间未响应';
|
|
|
|
|
|
|
|
|
|
+ /** 用时 → 人话(完成提醒专用;与 task-pulse 的 fmtDur 同口径,那边未导出) */
|
|
|
|
|
+ private static durText(ms: number): string {
|
|
|
|
|
+ const sec = Math.max(0, Math.round(ms / 1000));
|
|
|
|
|
+ if (sec < 60) return `${sec} 秒`;
|
|
|
|
|
+ const m = Math.floor(sec / 60);
|
|
|
|
|
+ const s = sec % 60;
|
|
|
|
|
+ return s ? `${m} 分 ${s} 秒` : `${m} 分钟`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
|
|
private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
|
|
|
/*
|
|
/*
|
|
|
* 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
|
|
* 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
|
|
@@ -1120,6 +1150,17 @@ export class MbChatPage implements OnDestroy {
|
|
|
protected readonly title = computed(
|
|
protected readonly title = computed(
|
|
|
() => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
|
|
() => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
|
|
|
);
|
|
);
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 在**当前容器**再开一条对话(2026-11-14)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 直接复用 `/chat/:agentId/new` —— 与 m-drawer 的「新建对话」最终落到同一条
|
|
|
|
|
+ * 建房链路(`startBlankWithRoom`),不新造逻辑。
|
|
|
|
|
+ */
|
|
|
|
|
+ protected newChatInCurrent(agentId: string): void {
|
|
|
|
|
+ this.drawerOpen.set(false);
|
|
|
|
|
+ void this.router.navigateByUrl(`/mobile/chat/${encodeURIComponent(agentId)}/new`);
|
|
|
|
|
+ }
|
|
|
/**
|
|
/**
|
|
|
* 容器名 + 状态(2026-11-11 收敛)。
|
|
* 容器名 + 状态(2026-11-11 收敛)。
|
|
|
*
|
|
*
|
|
@@ -1128,9 +1169,43 @@ export class MbChatPage implements OnDestroy {
|
|
|
* 离线时相位一律不显示(不能说明明断了还在回复)。
|
|
* 离线时相位一律不显示(不能说明明断了还在回复)。
|
|
|
*/
|
|
*/
|
|
|
protected readonly presence = computed(() =>
|
|
protected readonly presence = computed(() =>
|
|
|
- sessionPresence(this.chat.agent(), this.chat.peerTyping() ? 'replying' : undefined),
|
|
|
|
|
|
|
+ this.taskPresence(this.chat.agent()),
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 「在线 + 在干什么」的合并判定(2026-11-14)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 与桌面端 `desktop/pages/chat/chat.ts::taskLine()` 同一套口径:
|
|
|
|
|
+ * ① 本回合刚收到 ✅ 且尚未刷新 → 直接「在线」,别让顶栏还转着
|
|
|
|
|
+ * ② 容器在跑 → 用 `pulse.describe()` 的一句话任务文案
|
|
|
|
|
+ * ③ 其余交回 `sessionPresence`(离线/回复中/在线)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 只在**在线且正在干活**时接管文案;离线时一律走 liveness,
|
|
|
|
|
+ * 不能因为本地还记着"刚才在跑"就瞒着用户说它在线。
|
|
|
|
|
+ */
|
|
|
|
|
+ private taskPresence(
|
|
|
|
|
+ agent: Agent | undefined,
|
|
|
|
|
+ ): { text: string; tone: SemanticTone; busy: boolean } {
|
|
|
|
|
+ const base = sessionPresence(agent, this.chat.peerTyping() ? 'replying' : undefined);
|
|
|
|
|
+ if (!base.busy && base.text === '在线' && this.chat.turnSettledLocally()) {
|
|
|
|
|
+ return { text: '在线', tone: 'success', busy: false };
|
|
|
|
|
+ }
|
|
|
|
|
+ const line = this.pulse.describe(
|
|
|
|
|
+ agent,
|
|
|
|
|
+ Date.now(),
|
|
|
|
|
+ this.chat.sessionId(),
|
|
|
|
|
+ this.chat.currentRoomId(),
|
|
|
|
|
+ this.chat.myMxId(),
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!line.active) return base;
|
|
|
|
|
+ const task = this.chat.sessionTask();
|
|
|
|
|
+ return {
|
|
|
|
|
+ text: task || line.text || '正在处理…',
|
|
|
|
|
+ tone: line.tone === 'neutral' ? 'info' : line.tone,
|
|
|
|
|
+ busy: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
protected readonly subtitle = computed(() => {
|
|
protected readonly subtitle = computed(() => {
|
|
|
const a = this.chat.agent();
|
|
const a = this.chat.agent();
|
|
|
if (!a) return '';
|
|
if (!a) return '';
|
|
@@ -1313,6 +1388,37 @@ export class MbChatPage implements OnDestroy {
|
|
|
if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
|
|
if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 任务完成即时提醒(2026-11-14)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 为什么需要:用户在手机上切走看别的,回来时容器早就干完了,
|
|
|
|
|
+ * 界面上什么都看不出来 —— 只能自己去翻成果抽屉猜「到底完没完」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 数据源**不新造**:`TaskPulseService` 的 running → idle 翻转已经算好了
|
|
|
|
|
+ * (`finished` 脉冲自带任务描述与用时)。这里只做「新完成的脉冲 → 弹一次」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 只提醒**本容器**的完成:别的容器在跑什么,用户在另一个会话里自然能看到,
|
|
|
|
|
+ * 在当前会话里弹别人的完成提示只会造成干扰。
|
|
|
|
|
+ */
|
|
|
|
|
+ let seenPulseAt = 0;
|
|
|
|
|
+ effect(() => {
|
|
|
|
|
+ const agentId = this.chat.agent()?.id;
|
|
|
|
|
+ const all = this.pulse.pulses();
|
|
|
|
|
+ if (!agentId) return;
|
|
|
|
|
+ const mine = all.filter((p) => p.kind === 'finished' && p.agentId === agentId);
|
|
|
|
|
+ const latest = mine[0];
|
|
|
|
|
+ if (!latest) return;
|
|
|
|
|
+ /* 首次进入只建立基线,不补弹历史完成 */
|
|
|
|
|
+ if (seenPulseAt === 0) {
|
|
|
|
|
+ seenPulseAt = latest.at;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (latest.at <= seenPulseAt) return;
|
|
|
|
|
+ seenPulseAt = latest.at;
|
|
|
|
|
+ const spent = latest.durationMs ? `,用时 ${MbChatPage.durText(latest.durationMs)}` : '';
|
|
|
|
|
+ this.toast.success(`${latest.task ? latest.task + ' 已完成' : '任务已完成'}${spent}`);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
/*
|
|
/*
|
|
|
* 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
|
|
* 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
|
|
|
* 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
|
|
* 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
|