|
@@ -49,6 +49,7 @@ import { ChatSessionService } from './chat-session.service';
|
|
|
import { RelayService } from './relay.service';
|
|
import { RelayService } from './relay.service';
|
|
|
import { TaskPulseService } from './task-pulse.service';
|
|
import { TaskPulseService } from './task-pulse.service';
|
|
|
import { sessionRunning, sessionStateOf } from './agent-state';
|
|
import { sessionRunning, sessionStateOf } from './agent-state';
|
|
|
|
|
+import { resolveSessionPhase } from './agent-liveness';
|
|
|
|
|
|
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
|
|
|
|
|
@@ -357,6 +358,67 @@ export class ChatService {
|
|
|
/** 是否可能还有更早历史(真实态:上次分页仍有返回) */
|
|
/** 是否可能还有更早历史(真实态:上次分页仍有返回) */
|
|
|
readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
|
|
readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本会话是否**正在执行**(容器在跑这一轮)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用于「等待中」拦截:容器 `busy_input_mode=interrupt`,
|
|
|
|
|
+ * 直接发会把正在跑的任务打断,所以要先让用户确认。
|
|
|
|
|
+ */
|
|
|
|
|
+ readonly sessionBusy = computed(() => {
|
|
|
|
|
+ const s = this.sessionSignals();
|
|
|
|
|
+ return resolveSessionPhase({ ...s, turnLive: false }).busy;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 待发消息(2026-11-13 用户设计)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么有它
|
|
|
|
|
+ *
|
|
|
|
|
+ * 容器配置 `busy_input_mode=interrupt` —— 新消息到达会**打断当前 turn**。
|
|
|
|
|
+ * 直接发出去的话,用户在长任务中途随手补一句,前面的活全白做。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 但反过来用 `queue` 又不行:用户「改主意」时得干等几分钟。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 折中方案(用户裁决)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 本会话正在执行时,新消息**不立即发出**,而是先存为待发:
|
|
|
|
|
+ * · 输入框上方显示「上一条还在执行 · [撤回] [打断发送]」
|
|
|
|
|
+ * · 点「打断发送」→ 立刻发出(容器 interrupt 生效)
|
|
|
|
|
+ * · 点「撤回」→ 丢弃
|
|
|
|
|
+ * · **都不点** → 当前任务结束后自动发出
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly _heldMessage = signal<{ text: string; at: number } | null>(null);
|
|
|
|
|
+ /** 待发消息(null = 没有)。UI 据此渲染提示条 */
|
|
|
|
|
+ readonly heldMessage = this._heldMessage.asReadonly();
|
|
|
|
|
+
|
|
|
|
|
+ /** 撤回待发消息 */
|
|
|
|
|
+ withdrawHeld(): void {
|
|
|
|
|
+ this._heldMessage.set(null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 立即发出待发消息(打断当前任务) */
|
|
|
|
|
+ async sendHeldNow(): Promise<void> {
|
|
|
|
|
+ const held = this._heldMessage();
|
|
|
|
|
+ if (!held) return;
|
|
|
|
|
+ this._heldMessage.set(null);
|
|
|
|
|
+ await this.forceSendText(held.text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本会话空闲后,自动把待发消息发出去。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用 effect 监听 `sessionBusy`:由 true → false 时触发一次。
|
|
|
|
|
+ */
|
|
|
|
|
+ private armHeldAutoSend(): void {
|
|
|
|
|
+ effect(() => {
|
|
|
|
|
+ const busy = this.sessionBusy();
|
|
|
|
|
+ const held = this._heldMessage();
|
|
|
|
|
+ if (busy || !held) return;
|
|
|
|
|
+ this._heldMessage.set(null);
|
|
|
|
|
+ void this.forceSendText(held.text);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
readonly agent = computed<Agent | undefined>(() => {
|
|
readonly agent = computed<Agent | undefined>(() => {
|
|
|
const id = this._agentId();
|
|
const id = this._agentId();
|
|
|
return id ? this.live.agentById(id) : undefined;
|
|
return id ? this.live.agentById(id) : undefined;
|
|
@@ -443,6 +505,8 @@ export class ChatService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
constructor() {
|
|
constructor() {
|
|
|
|
|
+ /* 待发消息:会话空闲后自动发出(2026-11-13) */
|
|
|
|
|
+ this.armHeldAutoSend();
|
|
|
/**
|
|
/**
|
|
|
* 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
|
|
* 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
|
|
|
*
|
|
*
|
|
@@ -1768,6 +1832,22 @@ export class ChatService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async sendText(text: string): Promise<void> {
|
|
async sendText(text: string): Promise<void> {
|
|
|
|
|
+ await this.sendTextInternal(text, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 强制发送(跳过「等待中」拦截)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 供 `sendHeldNow()`(用户点「打断发送」)与 `armHeldAutoSend()`(自动发出)使用。
|
|
|
|
|
+ */
|
|
|
|
|
+ private async forceSendText(text: string): Promise<void> {
|
|
|
|
|
+ await this.sendTextInternal(text, true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @param force 跳过「本会话正在执行 → 先拦住」的检查
|
|
|
|
|
+ */
|
|
|
|
|
+ private async sendTextInternal(text: string, force: boolean): Promise<void> {
|
|
|
const body = text.trim();
|
|
const body = text.trim();
|
|
|
/*
|
|
/*
|
|
|
* 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
|
|
* 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
|
|
@@ -1796,6 +1876,23 @@ export class ChatService {
|
|
|
const conversationId = this._agentId();
|
|
const conversationId = this._agentId();
|
|
|
if (!conversationId) return;
|
|
if (!conversationId) return;
|
|
|
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 本会话正在执行 → **先拦住**,不直接发(2026-11-13 用户设计)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 容器 `busy_input_mode=interrupt`:直接发会打断正在跑的任务。
|
|
|
|
|
+ * 这里改成「存为待发 + 提示条让用户选」:
|
|
|
|
|
+ * · 打断发送 → sendHeldNow()
|
|
|
|
|
+ * · 撤回 → withdrawHeld()
|
|
|
|
|
+ * · 都不点 → 会话空闲后由 armHeldAutoSend() 自动发出
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 附件(`_pendingFiles`)不走这条路径:附件已上传到待发区,
|
|
|
|
|
+ * 单纯文字才拦截 —— 混合发送保持原行为,避免附件重复上传。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (!force && body && this._pendingFiles().length === 0 && this.sessionBusy()) {
|
|
|
|
|
+ this._heldMessage.set({ text: body, at: Date.now() });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/*
|
|
/*
|
|
|
* 乐观消息 id(2026-11-09)。
|
|
* 乐观消息 id(2026-11-09)。
|
|
|
*
|
|
*
|