diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index e1860099..35bc6db2 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -1444,6 +1444,17 @@ async def handle_task_with_sender( socketio.start_background_task(run_bg_poll) has_running_completion_jobs = has_running_sub_agents or has_running_background_commands + pending_runtime_guidance_messages: List[str] = [] + try: + from .tasks import task_manager + + pending_runtime_guidance_messages = task_manager.consume_runtime_guidance_messages( + username=username, + task_id=client_sid, + ) + except Exception as exc: + debug_log(f"[RuntimeGuidance] 读取剩余引导消息失败: {exc}") + pending_runtime_guidance_messages = [] # 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态) if not has_running_completion_jobs: @@ -1458,4 +1469,5 @@ async def handle_task_with_sender( # 沿用子智能体字段,确保前端直接走已验证通路 'has_running_sub_agents': has_running_completion_jobs, 'has_running_background_commands': has_running_background_commands, + 'pending_runtime_guidance_messages': pending_runtime_guidance_messages, }) diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 94e947a1..c52e5662 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -954,6 +954,52 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie if tool_failed: mark_force_thinking(web_terminal, reason=f"{function_name}_failed") + # 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入, + # 避免一轮内并行/多工具调用时过早插入。 + try: + from .tasks import task_manager + + runtime_guidance_original = task_manager.pop_runtime_guidance_for_injection( + username=username, task_id=client_sid + ) + except Exception as exc: + runtime_guidance_original = None + debug_log(f"[RuntimeGuidance] 读取引导队列失败: {exc}") + + if runtime_guidance_original: + runtime_guidance_text = runtime_guidance_original + runtime_guidance_meta = { + "runtime_guidance": True, + "runtime_guidance_original": runtime_guidance_original, + } + try: + web_terminal.context_manager.add_conversation( + "user", + runtime_guidance_text, + metadata=runtime_guidance_meta, + ) + except Exception as exc: + debug_log(f"[RuntimeGuidance] 持久化引导消息失败: {exc}") + messages.append( + { + "role": "user", + "content": runtime_guidance_text, + "metadata": runtime_guidance_meta, + } + ) + sender( + "user_message", + { + "message": runtime_guidance_text, + "conversation_id": conversation_id, + "runtime_guidance": True, + "runtime_guidance_original": runtime_guidance_original, + }, + ) + debug_log( + "[RuntimeGuidance] 已在工具结果批次结束后注入引导消息 " + f"task_id={client_sid}" + ) web_terminal._tool_loop_active = previous_tool_loop_active return {"stopped": False, "last_tool_call_time": last_tool_call_time} diff --git a/server/tasks.py b/server/tasks.py index c4587970..217673d1 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -38,6 +38,8 @@ class TaskRecord: "session_data", "stop_requested", "next_event_idx", + "runtime_pending_queue", + "runtime_guidance_queue", ) def __init__( @@ -72,6 +74,8 @@ class TaskRecord: self.session_data: Dict[str, Any] = {} self.stop_requested: bool = False self.next_event_idx: int = 0 + self.runtime_pending_queue: List[Dict[str, Any]] = [] + self.runtime_guidance_queue: List[str] = [] class TaskManager: @@ -199,6 +203,233 @@ class TaskManager: debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空") return True + @staticmethod + def _normalize_runtime_pending_queue(raw_queue: Any) -> List[Dict[str, Any]]: + now_ts = time.time() + normalized: List[Dict[str, Any]] = [] + if not isinstance(raw_queue, list): + return normalized + for raw_item in raw_queue: + if isinstance(raw_item, dict): + item_id = str(raw_item.get("id") or "").strip() + text = str(raw_item.get("text") or "").strip() + created_at = raw_item.get("created_at") + else: + item_id = "" + text = str(raw_item or "").strip() + created_at = None + if not text: + continue + if not item_id: + item_id = str(uuid.uuid4()) + try: + created_at_float = float(created_at) + except Exception: + created_at_float = now_ts + normalized.append( + { + "id": item_id, + "text": text, + "created_at": created_at_float, + } + ) + return normalized + + @staticmethod + def _runtime_pending_public(queue: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + for item in queue or []: + if not isinstance(item, dict): + continue + text = str(item.get("text") or "").strip() + item_id = str(item.get("id") or "").strip() + if not text or not item_id: + continue + result.append( + { + "id": item_id, + "text": text, + "created_at": item.get("created_at"), + } + ) + return result + + def enqueue_runtime_pending_message( + self, username: str, task_id: str, message: str, max_queue_size: int = 5 + ) -> Dict[str, Any]: + text = str(message or "").strip() + if not text: + return {"success": False, "code": "empty_message", "error": "消息不能为空"} + limit = int(max(1, max_queue_size)) + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法追加消息"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + if len(queue) >= limit: + return { + "success": False, + "code": "queue_full", + "error": f"堆积消息已满(最多 {limit} 条)", + } + item = { + "id": str(uuid.uuid4()), + "text": text, + "created_at": time.time(), + } + queue.append(item) + rec.runtime_pending_queue = queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "item": item, + "messages": self._runtime_pending_public(queue), + } + + def remove_runtime_pending_message( + self, username: str, task_id: str, message_id: str + ) -> Dict[str, Any]: + target_id = str(message_id or "").strip() + if not target_id: + return {"success": False, "code": "invalid_message_id", "error": "消息ID无效"} + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + remove_idx = -1 + for idx, item in enumerate(queue): + if str(item.get("id") or "") == target_id: + remove_idx = idx + break + if remove_idx < 0: + return {"success": False, "code": "message_not_found", "error": "消息不存在"} + queue.pop(remove_idx) + rec.runtime_pending_queue = queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "messages": self._runtime_pending_public(queue), + } + + def promote_runtime_pending_to_guidance( + self, + username: str, + task_id: str, + message_id: str, + max_guidance_queue_size: int = 5, + ) -> Dict[str, Any]: + target_id = str(message_id or "").strip() + if not target_id: + return {"success": False, "code": "invalid_message_id", "error": "消息ID无效"} + guidance_limit = int(max(1, max_guidance_queue_size)) + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法引导"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + guidance_queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(guidance_queue, list): + guidance_queue = [] + if len(guidance_queue) >= guidance_limit: + return { + "success": False, + "code": "guidance_queue_full", + "error": f"引导队列已满(最多 {guidance_limit} 条)", + } + selected = None + remain_queue: List[Dict[str, Any]] = [] + for item in queue: + if selected is None and str(item.get("id") or "") == target_id: + selected = item + continue + remain_queue.append(item) + if not selected: + return {"success": False, "code": "message_not_found", "error": "消息不存在"} + selected_text = str(selected.get("text") or "").strip() + if not selected_text: + return {"success": False, "code": "empty_message", "error": "消息内容为空"} + guidance_queue.append(selected_text) + rec.runtime_guidance_queue = guidance_queue + rec.runtime_pending_queue = remain_queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "queued_count": len(guidance_queue), + "messages": self._runtime_pending_public(remain_queue), + } + + def get_runtime_pending_messages(self, username: str, task_id: str) -> List[Dict[str, Any]]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return [] + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + rec.runtime_pending_queue = queue + return self._runtime_pending_public(queue) + + def enqueue_runtime_guidance( + self, username: str, task_id: str, message: str, max_queue_size: int = 5 + ) -> Dict[str, Any]: + text = str(message or "").strip() + if not text: + return {"success": False, "code": "empty_message", "error": "引导内容不能为空"} + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法追加引导"} + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list): + queue = [] + rec.runtime_guidance_queue = queue + if len(queue) >= int(max(1, max_queue_size)): + return { + "success": False, + "code": "queue_full", + "error": f"引导队列已满(最多 {int(max(1, max_queue_size))} 条)", + } + queue.append(text) + rec.updated_at = time.time() + return { + "success": True, + "queued_count": len(queue), + "task_id": rec.task_id, + } + + def pop_runtime_guidance_for_injection(self, username: str, task_id: str) -> Optional[str]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return None + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list) or not queue: + return None + item = str(queue.pop(0) or "").strip() + rec.updated_at = time.time() + return item or None + + def consume_runtime_guidance_messages(self, username: str, task_id: str) -> List[str]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return [] + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list) or not queue: + return [] + items = [str(item or "").strip() for item in queue] + rec.runtime_guidance_queue = [] + rec.updated_at = time.time() + return [item for item in items if item] + # ---- internal helpers ---- def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]): with self._lock: @@ -574,6 +805,9 @@ def get_task_api(task_id: str): "error": rec.error, "events": events, "next_offset": next_offset, + "runtime_queued_messages": task_manager.get_runtime_pending_messages( + username, task_id + ), } }) @@ -586,3 +820,109 @@ def cancel_task_api(task_id: str): if not ok: return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": True}) + + +@tasks_bp.route("/api/tasks//runtime_guidance", methods=["POST"]) +@api_login_required +def enqueue_runtime_guidance_api(task_id: str): + username = get_current_username() + payload = request.get_json() or {} + message = (payload.get("message") or "").strip() + if not message: + return jsonify({"success": False, "error": "引导内容不能为空"}), 400 + + result = task_manager.enqueue_runtime_guidance(username, task_id, message) + if not result.get("success"): + code = result.get("code") or "runtime_guidance_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code in {"queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "追加引导失败"}), 400 + + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "queued_count": result.get("queued_count", 0), + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue", methods=["POST"]) +@api_login_required +def enqueue_runtime_queue_message_api(task_id: str): + username = get_current_username() + payload = request.get_json() or {} + message = (payload.get("message") or "").strip() + if not message: + return jsonify({"success": False, "error": "消息不能为空"}), 400 + result = task_manager.enqueue_runtime_pending_message(username, task_id, message) + if not result.get("success"): + code = result.get("code") or "runtime_queue_enqueue_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code in {"queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "追加消息失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "item": result.get("item"), + "messages": result.get("messages") or [], + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue/", methods=["DELETE"]) +@api_login_required +def delete_runtime_queue_message_api(task_id: str, message_id: str): + username = get_current_username() + result = task_manager.remove_runtime_pending_message(username, task_id, message_id) + if not result.get("success"): + code = result.get("code") or "runtime_queue_delete_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code == "message_not_found": + return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404 + return jsonify({"success": False, "error": result.get("error") or "删除失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "messages": result.get("messages") or [], + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue//guide", methods=["POST"]) +@api_login_required +def guide_runtime_queue_message_api(task_id: str, message_id: str): + username = get_current_username() + result = task_manager.promote_runtime_pending_to_guidance(username, task_id, message_id) + if not result.get("success"): + code = result.get("code") or "runtime_queue_guide_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code == "message_not_found": + return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404 + if code in {"guidance_queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "引导失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "queued_count": result.get("queued_count", 0), + "messages": result.get("messages") or [], + }, + } + ) diff --git a/static/src/App.vue b/static/src/App.vue index 8daadf3a..8bb74076 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -121,6 +121,7 @@ 'chat-container--monitor': chatDisplayMode === 'monitor', 'has-title-ribbon': titleRibbonVisible }" + :style="chatContainerStyle" > diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index e2110b3a..a60fe9f1 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -129,6 +129,17 @@ export const computed = { titleRibbonVisible() { return !this.isMobileViewport && this.chatDisplayMode === 'chat'; }, + chatContainerStyle() { + const minHeight = 80; + const raw = Number(this.composerReservedHeight || 0); + const height = Number.isFinite(raw) ? Math.max(minHeight, Math.round(raw)) : minHeight; + const growth = Math.max(0, height - minHeight); + return { + '--composer-base-height': `calc(${minHeight}px + var(--app-bottom-inset, 0px))`, + '--composer-reserved-height': `calc(${height}px + var(--app-bottom-inset, 0px))`, + '--composer-growth-height': `${growth}px` + }; + }, ...mapWritableState(useToolStore, [ 'preparingTools', 'activeTools', diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 22da0e0f..2c4d09ed 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -94,6 +94,18 @@ export const conversationMethods = { this.inputSetLineCount(1); this.inputSetMultiline(false); this.inputClearMessage(); + this.runtimeQueuedMessages = []; + this.runtimeGuidanceFallbackQueue = []; + this.runtimeQueueAutoSendInProgress = false; + this.runtimeQueueSyncLockKey = ''; + this.runtimeQueueSyncLockUntil = 0; + if (typeof this.clearRuntimeQueueSuppressionState === 'function') { + this.clearRuntimeQueueSuppressionState(); + } else { + this.runtimeQueueSuppressedMessageIds = new Set(); + this.runtimeGuidanceSuppressedTextCounts = {}; + } + this.composerReservedHeight = 80; this.inputClearSelectedImages(); this.inputSetImagePickerOpen(false); this.imageEntries = []; diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index b0e861df..5938de2b 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -3,6 +3,114 @@ import { debugLog } from './common'; import { useModelStore } from '../../stores/model'; export const messageMethods = { + buildRuntimeQueueSnapshotKey(messages = []) { + const list = Array.isArray(messages) ? messages : []; + return JSON.stringify( + list.map((item) => [ + String(item?.id || ''), + String(item?.text || ''), + Number(item?.createdAt || 0) + ]) + ); + }, + + setRuntimeQueueSyncLock(messages = [], ttlMs = 1800) { + this.runtimeQueueSyncLockKey = this.buildRuntimeQueueSnapshotKey(messages); + this.runtimeQueueSyncLockUntil = Date.now() + Math.max(300, Number(ttlMs || 0)); + }, + + ensureRuntimeQueueSuppressionState() { + if (!(this.runtimeQueueSuppressedMessageIds instanceof Set)) { + this.runtimeQueueSuppressedMessageIds = new Set(); + } + if ( + !this.runtimeGuidanceSuppressedTextCounts || + typeof this.runtimeGuidanceSuppressedTextCounts !== 'object' || + Array.isArray(this.runtimeGuidanceSuppressedTextCounts) + ) { + this.runtimeGuidanceSuppressedTextCounts = {}; + } + }, + + markRuntimeQueueSuppressedByManualStop() { + this.ensureRuntimeQueueSuppressionState(); + const queueList = Array.isArray(this.runtimeQueuedMessages) ? this.runtimeQueuedMessages : []; + queueList.forEach((item) => { + const id = String(item?.id || '').trim(); + if (id) { + this.runtimeQueueSuppressedMessageIds.add(id); + } + }); + + const fallbackList = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + fallbackList.forEach((item) => { + const text = String(item || '').trim(); + if (!text) return; + const counts = this.runtimeGuidanceSuppressedTextCounts; + counts[text] = Number(counts[text] || 0) + 1; + }); + }, + + consumeSuppressedRuntimeGuidanceText(rawText) { + this.ensureRuntimeQueueSuppressionState(); + const text = String(rawText || '').trim(); + if (!text) return false; + const counts = this.runtimeGuidanceSuppressedTextCounts; + const current = Number(counts[text] || 0); + return Number.isFinite(current) && current > 0; + }, + + consumeSuppressedRuntimeQueueMessageId(rawId) { + this.ensureRuntimeQueueSuppressionState(); + const id = String(rawId || '').trim(); + if (!id) return false; + return this.runtimeQueueSuppressedMessageIds.has(id); + }, + + clearRuntimeQueueSuppressionState() { + this.runtimeQueueSuppressedMessageIds = new Set(); + this.runtimeGuidanceSuppressedTextCounts = {}; + }, + + pruneSuppressedRuntimeQueues() { + this.ensureRuntimeQueueSuppressionState(); + const blockedIds = this.runtimeQueueSuppressedMessageIds; + const currentQueue = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + const nextQueue = currentQueue.filter((item) => { + const id = String(item?.id || '').trim(); + if (!id) return false; + return !blockedIds.has(id); + }); + if (nextQueue.length !== currentQueue.length) { + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } + + const currentFallback = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + if (currentFallback.length > 0) { + const keptFallback = []; + currentFallback.forEach((item) => { + const text = String(item || '').trim(); + if (!text) { + return; + } + if (this.consumeSuppressedRuntimeGuidanceText(text)) { + return; + } + keptFallback.push(text); + }); + if (keptFallback.length !== currentFallback.length) { + this.runtimeGuidanceFallbackQueue = keptFallback; + } + } + }, + async executeSystemCommand(rawCommand, options = {}) { const command = (rawCommand || '').toString().trim(); if (!command) { @@ -102,7 +210,349 @@ export const messageMethods = { } }, - handleSendOrStop() { + applyRuntimeQueuedMessages(messages = []) { + this.ensureRuntimeQueueSuppressionState(); + const previousList = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + const previousById = new Map(previousList.map((item) => [item?.id, item])); + const previousIndexById = new Map(previousList.map((item, index) => [item?.id, index])); + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + const normalizedRaw = (Array.isArray(messages) ? messages : []) + .map((item) => { + if (!item) return null; + const id = String(item.id || '').trim(); + const text = String(item.text || '').trim(); + if (!id || !text) return null; + const previous = previousById.get(id); + const rawCreatedAt = Number(item.created_at ?? item.createdAt ?? Date.now()); + return { + id, + text, + createdAt: Number.isFinite(rawCreatedAt) ? rawCreatedAt : Date.now(), + source: previous?.source || 'user' + }; + }) + .filter((item) => !!item); + + const dedupById = new Map(); + normalizedRaw.forEach((item) => { + if (!item?.id || dedupById.has(item.id)) { + return; + } + dedupById.set(item.id, item); + }); + + const normalized = Array.from(dedupById.values()) + .sort((a, b) => { + const at = Number(a?.createdAt || 0); + const bt = Number(b?.createdAt || 0); + if (at !== bt) { + return at - bt; + } + const ai = previousIndexById.has(a?.id) + ? Number(previousIndexById.get(a?.id)) + : Number.MAX_SAFE_INTEGER; + const bi = previousIndexById.has(b?.id) + ? Number(previousIndexById.get(b?.id)) + : Number.MAX_SAFE_INTEGER; + if (ai !== bi) { + return ai - bi; + } + return String(a?.id || '').localeCompare(String(b?.id || '')); + }) + .slice(0, limit); + + const unchanged = + previousList.length === normalized.length && + previousList.every((item, index) => { + const next = normalized[index]; + return ( + item?.id === next?.id && + item?.text === next?.text && + Number(item?.createdAt || 0) === Number(next?.createdAt || 0) + ); + }); + + if (unchanged) { + return previousList; + } + this.runtimeQueuedMessages = normalized; + return normalized; + }, + + async enqueueRuntimeQueuedMessage(rawMessage) { + const text = (rawMessage || '').toString().trim(); + if (!text) { + return false; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + this.uiPushToast({ + title: '暂不可堆积', + message: '未检测到活跃任务,请稍后再试', + type: 'warning' + }); + return false; + } + const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/runtime_queue`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: text + }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '堆积消息失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + return true; + } catch (error) { + this.uiPushToast({ + title: '堆积失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + return false; + } + }, + + async handleDeleteRuntimeMessage(messageId) { + const targetId = String(messageId || '').trim(); + if (!targetId) { + return; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + const nextQueue = (this.runtimeQueuedMessages || []).filter( + (item) => item?.id !== targetId + ); + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 1200); + return; + } + const response = await fetch( + `/api/tasks/${encodeURIComponent(taskId)}/runtime_queue/${encodeURIComponent(targetId)}`, + { + method: 'DELETE' + } + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '删除失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } catch (error) { + this.uiPushToast({ + title: '删除失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } + }, + + async handleGuideRuntimeMessage(messageId) { + const item = (this.runtimeQueuedMessages || []).find((entry) => entry?.id === messageId); + if (!item?.text) { + return; + } + if (!this.composerBusy) { + const sent = !!(await this.sendMessage({ + presetText: String(item.text || '').trim(), + source: 'runtime_queue_manual_guide' + })); + if (!sent) { + this.uiPushToast({ + title: '发送失败', + message: '引导消息未发出,请稍后重试', + type: 'warning' + }); + return; + } + const currentQueue = Array.isArray(this.runtimeQueuedMessages) + ? [...this.runtimeQueuedMessages] + : []; + const nextQueue = currentQueue.filter((entry) => entry?.id !== item.id); + if (nextQueue.length !== currentQueue.length) { + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } + this.ensureRuntimeQueueSuppressionState(); + if (item?.id) { + this.runtimeQueueSuppressedMessageIds.delete(item.id); + } + return; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + this.uiPushToast({ + title: '暂不可引导', + message: '未检测到活跃任务,请稍后再试', + type: 'warning' + }); + return; + } + const response = await fetch( + `/api/tasks/${encodeURIComponent(taskId)}/runtime_queue/${encodeURIComponent(messageId)}/guide`, + { + method: 'POST' + } + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '提交引导失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + this.uiPushToast({ + title: '已设为引导对话', + message: '将在下一次工具结果后插入到当前对话', + type: 'success', + duration: 1800 + }); + } catch (error) { + this.uiPushToast({ + title: '引导失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } + }, + + async tryAutoSendRuntimeQueuedMessages(reason = 'unspecified') { + if (this.runtimeQueueAutoSendInProgress) { + return false; + } + if ( + this.composerBusy || + this.streamingMessage || + this.taskInProgress || + this.stopRequested || + this.waitingForSubAgent + ) { + return false; + } + + const fallbackQueue = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + const runtimeQueue = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + let nextText = ''; + let source = 'runtime_queue'; + let runtimeQueueMessageId = null; + let fallbackIndex = -1; + + if (fallbackQueue.length > 0) { + for (let i = 0; i < fallbackQueue.length; i += 1) { + const candidate = (fallbackQueue[i] || '').toString().trim(); + if (!candidate) { + continue; + } + if (this.consumeSuppressedRuntimeGuidanceText(candidate)) { + continue; + } + nextText = candidate; + source = 'runtime_guidance_fallback'; + fallbackIndex = i; + break; + } + } + + if (!nextText && runtimeQueue.length > 0) { + for (let i = 0; i < runtimeQueue.length; i += 1) { + const next = runtimeQueue[i]; + const candidateId = String(next?.id || '').trim(); + const candidateText = (next?.text || '').toString().trim(); + if (!candidateId || !candidateText) { + continue; + } + if (this.consumeSuppressedRuntimeQueueMessageId(candidateId)) { + continue; + } + nextText = candidateText; + source = next?.source || 'runtime_queue'; + runtimeQueueMessageId = candidateId; + break; + } + } + + if (!nextText) { + return false; + } + + this.runtimeQueueAutoSendInProgress = true; + let sent = false; + try { + sent = !!(await this.sendMessage({ + presetText: nextText, + source + })); + } catch { + sent = false; + } finally { + this.runtimeQueueAutoSendInProgress = false; + } + + if (!sent) { + return false; + } + + if (source === 'runtime_guidance_fallback') { + const current = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? [...this.runtimeGuidanceFallbackQueue] + : []; + const removeIndex = + fallbackIndex >= 0 + ? fallbackIndex + : current.findIndex((item) => String(item || '').trim() === nextText); + if (removeIndex >= 0) { + current.splice(removeIndex, 1); + this.runtimeGuidanceFallbackQueue = current; + } + } else { + const current = Array.isArray(this.runtimeQueuedMessages) + ? [...this.runtimeQueuedMessages] + : []; + if (runtimeQueueMessageId) { + const nextQueue = current.filter((item) => item?.id !== runtimeQueueMessageId); + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 1500); + } else { + const removeIndex = current.findIndex( + (item) => String(item?.text || '').trim() === nextText + ); + if (removeIndex >= 0) { + current.splice(removeIndex, 1); + this.runtimeQueuedMessages = current; + this.setRuntimeQueueSyncLock(current, 1500); + } + } + } + debugLog('[RuntimeQueue] auto-send success', { + reason, + source, + messagePreview: nextText.slice(0, 60) + }); + return true; + }, + + async handleSendOrStop() { if (this.compressionInProgress) { this.uiPushToast({ title: '对话自动压缩中', @@ -111,15 +561,39 @@ export const messageMethods = { }); return; } + const hasText = !!((this.inputMessage || '').trim().length > 0); + const hasMedia = + (Array.isArray(this.selectedImages) && this.selectedImages.length > 0) || + (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); if (this.composerBusy) { + if (hasText) { + const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage); + if (queued) { + this.inputClearMessage(); + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.autoResizeInput(); + } + return; + } + if (hasMedia) { + this.uiPushToast({ + title: '运行中仅支持文本', + message: '图片/视频请等待当前任务结束后发送', + type: 'warning' + }); + return; + } this.stopTask(); } else { this.sendMessage(); } }, - async sendMessage() { + async sendMessage(options = {}) { console.log('[DEBUG_AWAITING] ===== sendMessage ====='); + const presetText = typeof options?.presetText === 'string' ? options.presetText : null; + const usePresetText = presetText !== null; if (this.compressionInProgress) { this.uiPushToast({ @@ -127,10 +601,10 @@ export const messageMethods = { message: '压缩完成后才能继续发送消息', type: 'warning' }); - return; + return false; } - if (this.streamingUi) { - return; + if (this.streamingUi && !usePresetText) { + return false; } if (!this.isConnected) { this.uiPushToast({ @@ -138,32 +612,40 @@ export const messageMethods = { message: '当前无法发送消息,请等待连接恢复后重试', type: 'warning' }); - return; + return false; } - if (this.mediaUploading) { + if (this.mediaUploading && !usePresetText) { this.uiPushToast({ title: '上传中', message: '请等待图片/视频上传完成后再发送', type: 'info' }); - return; + return false; } - const text = (this.inputMessage || '').trim(); - const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; - const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; + const text = ((usePresetText ? presetText : this.inputMessage) || '').trim(); + const images = usePresetText + ? [] + : Array.isArray(this.selectedImages) + ? this.selectedImages.slice(0, 9) + : []; + const videos = usePresetText + ? [] + : Array.isArray(this.selectedVideos) + ? this.selectedVideos.slice(0, 1) + : []; const hasText = text.length > 0; const hasImages = images.length > 0; const hasVideos = videos.length > 0; if (!hasText && !hasImages && !hasVideos) { - return; + return false; } const quotaType = this.thinkingMode ? 'thinking' : 'fast'; if (this.isQuotaExceeded(quotaType)) { this.showQuotaToast({ type: quotaType }); - return; + return false; } const modelStore = useModelStore(); @@ -174,7 +656,7 @@ export const messageMethods = { message: '请切换到支持图片输入的模型再发送图片', type: 'error' }); - return; + return false; } if (hasVideos && !currentModel?.supportsVideo) { @@ -183,7 +665,7 @@ export const messageMethods = { message: '请切换到支持视频输入的模型后再发送视频', type: 'error' }); - return; + return false; } if (hasVideos && hasImages) { @@ -192,7 +674,7 @@ export const messageMethods = { message: '视频与图片需分开发送,每条仅包含一种媒体', type: 'warning' }); - return; + return false; } if (hasVideos) { @@ -256,7 +738,7 @@ export const messageMethods = { message: error?.message || '创建新对话失败,请重试', type: 'error' }); - return; + return false; } } @@ -305,21 +787,23 @@ export const messageMethods = { if (typeof this.forceUnlockMonitor === 'function') { this.forceUnlockMonitor('create_task_failed'); } - return; + return false; } - this.inputClearMessage(); - this.inputClearSelectedImages(); - this.inputClearSelectedVideos(); - this.inputSetImagePickerOpen(false); - this.inputSetVideoPickerOpen(false); - this.inputSetLineCount(1); - this.inputSetMultiline(false); - this.persistComposerDraftNow({ - reason: 'send-message-cleared', - force: true, - keepalive: true - }).catch(() => {}); + if (!usePresetText) { + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.inputSetImagePickerOpen(false); + this.inputSetVideoPickerOpen(false); + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.persistComposerDraftNow({ + reason: 'send-message-cleared', + force: true, + keepalive: true + }).catch(() => {}); + } if (hasImages) { this.conversationHasImages = true; this.conversationHasVideos = false; @@ -339,6 +823,7 @@ export const messageMethods = { this.updateCurrentContextTokens(); } }, 1000); + return true; }, // 新增:停止任务方法 @@ -359,6 +844,9 @@ export const messageMethods = { } const shouldDropToolEvents = this.streamingUi; + if (typeof this.markRuntimeQueueSuppressedByManualStop === 'function') { + this.markRuntimeQueueSuppressedByManualStop(); + } this.stopRequested = true; this.dropToolEvents = shouldDropToolEvents; diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 2856b58a..18400df7 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -166,7 +166,8 @@ export const taskPollingMethods = { const tasks = Array.isArray(subResult?.data) ? subResult.data : []; const relevantTasks = tasks.filter((task: any) => { if (!task) return false; - if (task.conversation_id && task.conversation_id !== this.currentConversationId) return false; + if (task.conversation_id && task.conversation_id !== this.currentConversationId) + return false; return true; }); hasSubAgentPending = relevantTasks.some((task: any) => { @@ -202,8 +203,7 @@ export const taskPollingMethods = { return { resolved, - hasPending: - !resolved || hasSubAgentPending || hasBackgroundPending, + hasPending: !resolved || hasSubAgentPending || hasBackgroundPending, hasBackgroundPending }; }, @@ -568,6 +568,9 @@ export const taskPollingMethods = { case 'system_message': this.handleSystemMessage(eventData, eventIdx); break; + case 'runtime_queue_sync': + this.handleRuntimeQueueSync(eventData); + break; default: debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); @@ -1238,6 +1241,29 @@ export const taskPollingMethods = { handleTaskComplete(data: any) { const pendingToolsBefore = typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null; + const pendingRuntimeGuidance = Array.isArray(data?.pending_runtime_guidance_messages) + ? data.pending_runtime_guidance_messages + .map((item: any) => String(item || '').trim()) + .filter((item: string) => item.length > 0) + : []; + if (pendingRuntimeGuidance.length > 0) { + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + const mergedGuidance = [ + ...(this.runtimeGuidanceFallbackQueue || []), + ...pendingRuntimeGuidance + ] + .map((item: any) => String(item || '').trim()) + .filter((item: string) => item.length > 0) + .slice(0, limit); + const queueAllowance = Math.max(0, limit - mergedGuidance.length); + if ( + Array.isArray(this.runtimeQueuedMessages) && + this.runtimeQueuedMessages.length > queueAllowance + ) { + this.runtimeQueuedMessages = this.runtimeQueuedMessages.slice(0, queueAllowance); + } + this.runtimeGuidanceFallbackQueue = mergedGuidance; + } jsonDebug('handleTaskComplete:before', { data, taskInProgress: this.taskInProgress, @@ -1283,6 +1309,11 @@ export const taskPollingMethods = { this.waitingForBackgroundCommand = false; this.stopWaitingTaskProbe(); this.clearTaskState(); // 清理任务状态 + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_complete'); + } + }); } this.$forceUpdate(); @@ -1335,6 +1366,11 @@ export const taskPollingMethods = { this.$forceUpdate(); this.clearTaskState(); + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_stopped'); + } + }); jsonDebug('handleTaskStopped:after', { taskInProgress: this.taskInProgress, streamingMessage: this.streamingMessage, @@ -1342,6 +1378,55 @@ export const taskPollingMethods = { }); }, + handleRuntimeQueueSync(data: any) { + const messages = Array.isArray(data?.messages) ? data.messages : []; + const buildSnapshotKey = + typeof this.buildRuntimeQueueSnapshotKey === 'function' + ? this.buildRuntimeQueueSnapshotKey + : (list: any[]) => + JSON.stringify( + (Array.isArray(list) ? list : []).map((item: any) => [ + String(item?.id || ''), + String(item?.text || ''), + Number(item?.createdAt ?? item?.created_at ?? 0) + ]) + ); + const incomingPreview = (Array.isArray(messages) ? messages : []).map((item: any) => ({ + id: String(item?.id || '').trim(), + text: String(item?.text || '').trim(), + createdAt: Number(item?.created_at ?? item?.createdAt ?? 0) + })); + const incomingKey = buildSnapshotKey(incomingPreview); + const now = Date.now(); + const lockKey = String(this.runtimeQueueSyncLockKey || ''); + const lockUntil = Number(this.runtimeQueueSyncLockUntil || 0); + if (lockKey && now < lockUntil && incomingKey !== lockKey) { + debugLog('[RuntimeQueue] 忽略可能过期的轮询同步', { + now, + lockUntil, + incomingSize: incomingPreview.length + }); + return; + } + if (lockKey && incomingKey === lockKey) { + this.runtimeQueueSyncLockKey = ''; + this.runtimeQueueSyncLockUntil = 0; + } + if (typeof this.applyRuntimeQueuedMessages === 'function') { + this.applyRuntimeQueuedMessages(messages); + return; + } + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + this.runtimeQueuedMessages = messages + .map((item: any) => ({ + id: String(item?.id || '').trim(), + text: String(item?.text || '').trim(), + createdAt: Number(item?.created_at ?? item?.createdAt ?? Date.now()) + })) + .filter((item: any) => item.id && item.text) + .slice(0, limit); + }, + // 新增统一清理方法 clearTaskState() { this.stopWaitingTaskProbe(); @@ -1560,6 +1645,11 @@ export const taskPollingMethods = { const taskStore = useTaskStore(); taskStore.stopPolling(); })(); + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_error'); + } + }); }, handleTokenUpdate(data: any) { diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index ba6fc23e..f40b6f08 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -110,8 +110,7 @@ function connectionDiag( ...payload }; pushConnectionDiagRecord(record); - const logger = - level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; logger('[CONN_DIAG]', event, record); } @@ -370,7 +369,11 @@ export const uiMethods = { } const payloadText = JSON.stringify({ content }); - if (useBeacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + if ( + useBeacon && + typeof navigator !== 'undefined' && + typeof navigator.sendBeacon === 'function' + ) { try { const blob = new Blob([payloadText], { type: 'application/json' }); navigator.sendBeacon('/api/input-draft', blob); @@ -1857,6 +1860,21 @@ export const uiMethods = { return this.$refs.inputComposer || null; }, + handleComposerHeightChange(payload = {}) { + const raw = + Number(payload?.reservedHeight || 0) || + Number(payload?.height || 0) || + Number(payload?.composerHeight || 0); + if (!Number.isFinite(raw) || raw <= 0) { + return; + } + const next = Math.max(80, Math.min(560, Math.round(raw))); + if (Math.abs(next - Number(this.composerReservedHeight || 0)) < 1) { + return; + } + this.composerReservedHeight = next; + }, + getComposerElement(field) { const composer = this.getInputComposerRef(); const unwrap = (value: any) => { @@ -2291,7 +2309,8 @@ export const uiMethods = { if (wasConnected && shouldDisconnect) { this.connectionHeartbeatLastChangeAt = Date.now(); } - const shouldLogFail = wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0; + const shouldLogFail = + wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0; connectionDiag( shouldLogFail ? 'warn' : 'log', 'health-failed', @@ -2315,7 +2334,8 @@ export const uiMethods = { conversationId: this.currentConversationId || null, taskInProgress: !!this.taskInProgress, streamingMessage: !!this.streamingMessage, - hasPendingTools: typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null + hasPendingTools: + typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null }, { force: shouldLogFail } ); @@ -2351,7 +2371,8 @@ export const uiMethods = { return; } const connectedInterval = - typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0 + typeof this.connectionHeartbeatIntervalMs === 'number' && + this.connectionHeartbeatIntervalMs > 0 ? this.connectionHeartbeatIntervalMs : 8000; const disconnectedInterval = @@ -2360,7 +2381,10 @@ export const uiMethods = { ? this.connectionHeartbeatDisconnectedIntervalMs : 1000; const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; - if (isConnectionDiagEnabled() && (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3)) { + if ( + isConnectionDiagEnabled() && + (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3) + ) { connectionDiag('log', 'heartbeat-next', { seq: this.connectionHeartbeatSeq, isConnected: !!this.isConnected, diff --git a/static/src/app/state.ts b/static/src/app/state.ts index c0d45ccd..3ff38433 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -25,6 +25,17 @@ export function dataState() { taskInProgress: false, // 等待后台通知期间,用于发现并接管新建任务的轮询定时器 waitingTaskProbeTimer: null, + // 运行期消息堆积(提前发送 / 引导对话) + runtimeQueuedMessages: [], + runtimeGuidanceFallbackQueue: [], + runtimeQueueSuppressedMessageIds: new Set(), + runtimeGuidanceSuppressedTextCounts: {}, + runtimeQueueLimit: 5, + runtimeQueueAutoSendInProgress: false, + runtimeQueueSyncLockKey: '', + runtimeQueueSyncLockUntil: 0, + // 输入区动态保留高度(用于同步扩大消息区可滚动范围) + composerReservedHeight: 80, // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 lastHistoryLoadedConversationId: null, diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index 0f02f440..05fec2c7 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -14,6 +14,37 @@ export const watchers = { this.refreshBlankHeroState(); } }, + composerBusy(newValue, oldValue) { + if (oldValue && !newValue && typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('watch-composer-idle'); + } + }, + runtimeQueuedMessages: { + deep: true, + handler(list) { + if ( + Array.isArray(list) && + list.length > 0 && + !this.composerBusy && + typeof this.tryAutoSendRuntimeQueuedMessages === 'function' + ) { + this.tryAutoSendRuntimeQueuedMessages('watch-runtime-queue'); + } + } + }, + runtimeGuidanceFallbackQueue: { + deep: true, + handler(list) { + if ( + Array.isArray(list) && + list.length > 0 && + !this.composerBusy && + typeof this.tryAutoSendRuntimeQueuedMessages === 'function' + ) { + this.tryAutoSendRuntimeQueuedMessages('watch-runtime-guidance-fallback'); + } + } + }, currentConversationTitle(newVal, oldVal) { const target = (newVal && newVal.trim()) || ''; if (this.suppressTitleTyping) { diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 3f96fc97..3751cf34 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -1,6 +1,38 @@ @@ -503,5 +929,4 @@ onMounted(() => { .image-remove-btn-hover:hover { color: #ef4444; } - diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index 75b12963..cad2e68d 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -49,7 +49,8 @@ export const useTaskStore = defineStore('task', { pollingFailThreshold: 8, pollingIntervalMs: 250, taskCreatedAt: null as number | null, - taskUpdatedAt: null as number | null + taskUpdatedAt: null as number | null, + runtimeQueueSnapshotKey: '' }), getters: { @@ -104,6 +105,7 @@ export const useTaskStore = defineStore('task', { this.taskStatus = result.data.status; this.lastEventIndex = 0; this.taskCreatedAt = result.data.created_at; + this.runtimeQueueSnapshotKey = ''; this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; @@ -189,6 +191,30 @@ export const useTaskStore = defineStore('task', { // 更新任务状态 this.taskStatus = data.status; this.taskUpdatedAt = data.updated_at; + const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages) + ? data.runtime_queued_messages + : []; + const runtimeQueueSnapshotKey = JSON.stringify( + runtimeQueueMessages.map((item: any) => [ + String(item?.id || ''), + String(item?.text || '') + ]) + ); + if (runtimeQueueSnapshotKey !== this.runtimeQueueSnapshotKey) { + this.runtimeQueueSnapshotKey = runtimeQueueSnapshotKey; + try { + eventHandler({ + type: 'runtime_queue_sync', + data: { + task_id: data?.task_id || this.currentTaskId, + conversation_id: data?.conversation_id || null, + messages: runtimeQueueMessages + } + }); + } catch (err) { + console.warn('[Task] 处理 runtime_queue_sync 失败:', err); + } + } let sawTaskCompleteEvent = false; let sawTaskStoppedEvent = false; @@ -434,6 +460,7 @@ export const useTaskStore = defineStore('task', { this.lastEventIndex = 0; } this.pollingError = null; + this.runtimeQueueSnapshotKey = ''; this.startPolling(options.eventHandler); }, @@ -453,6 +480,7 @@ export const useTaskStore = defineStore('task', { this.isPolling = false; this.pollingInFlight = false; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; this.currentTaskId = null; // 清除任务 ID }, @@ -515,6 +543,7 @@ export const useTaskStore = defineStore('task', { this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; // 获取任务详情,计算已处理的事件数量 try { @@ -559,6 +588,7 @@ export const useTaskStore = defineStore('task', { this.pollingWarned = false; this.taskCreatedAt = null; this.taskUpdatedAt = null; + this.runtimeQueueSnapshotKey = ''; }, resetForNewConversation() { @@ -570,6 +600,7 @@ export const useTaskStore = defineStore('task', { this.pollingError = null; this.pollingErrorCount = 0; this.pollingWarned = false; + this.runtimeQueueSnapshotKey = ''; } } }); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index f11f6e39..75b71466 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1,7 +1,9 @@ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ .chat-container { --chat-surface-color: var(--theme-surface-strong, #ffffff); - --composer-reserved-height: calc(75px + var(--app-bottom-inset, 0px)); + --composer-base-height: calc(80px + var(--app-bottom-inset, 0px)); + --composer-reserved-height: var(--composer-base-height); + --composer-growth-height: 0px; flex: 1; display: flex; flex-direction: column; @@ -75,7 +77,7 @@ overflow-anchor: none; padding: 24px; padding-top: 30px; - padding-bottom: calc(20px + var(--app-bottom-inset, 0px)); + padding-bottom: calc(20px + var(--composer-growth-height, 0px)); min-height: 0; position: relative; } @@ -152,7 +154,8 @@ @media (max-width: 768px) { .chat-container { - --composer-reserved-height: calc(96px + var(--app-bottom-inset, 0px)); + --composer-base-height: calc(80px + var(--app-bottom-inset, 0px)); + --composer-reserved-height: var(--composer-base-height); } .chat-container--mobile { diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index bb8555d2..8eedab80 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -24,6 +24,115 @@ pointer-events: auto; } +.runtime-queue-list { + --runtime-queue-bg: var(--theme-surface-soft); + --runtime-queue-divider: rgba(15, 23, 42, 0.12); + position: absolute; + left: 50%; + bottom: calc(100% - 2px); + transform: translateX(-50%); + z-index: 1; + display: flex; + flex-direction: column; + width: 90%; + max-width: 90%; + margin: 0; + overflow: visible; + border: none; + background: transparent; + box-shadow: none; + pointer-events: auto; +} + +.runtime-queue-list--empty { + height: 0; + border: none; + box-shadow: none; + background: transparent; + margin: 0; + overflow: visible; + pointer-events: none; +} + +.runtime-queue-item { + position: relative; + z-index: var(--runtime-queue-z, 1); + min-height: 34px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 6px 10px 6px 12px; + border: none; + border-radius: 0; + background: var(--runtime-queue-bg); +} + +.runtime-queue-item--top { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +.runtime-queue-item__text { + min-width: 0; + font-size: 13px; + line-height: 1.3; + color: var(--claude-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.runtime-queue-item__action { + border: none; + background: transparent; + color: var(--claude-text-secondary); + font-size: 12px; + line-height: 1.2; + padding: 3px 4px; + border-radius: 6px; + cursor: pointer; +} + +.runtime-queue-item__action:hover { + background: var(--theme-chip-bg); + color: var(--claude-text); +} + +.runtime-queue-item__action--danger:hover { + color: #b91c1c; + background: rgba(185, 28, 28, 0.12); +} + +.runtime-queue-item + .runtime-queue-item::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 1px; + background: var(--runtime-queue-divider); + pointer-events: none; +} + +body[data-theme='dark'] { + .runtime-queue-list { + --runtime-queue-bg: #2a2a2a; + --runtime-queue-divider: rgba(255, 255, 255, 0.12); + background: transparent; + } + + .runtime-queue-item { + background: var(--runtime-queue-bg); + } + + .runtime-queue-list.runtime-queue-list--empty { + background: transparent; + border: none; + box-shadow: none; + } +} + .permission-switcher { position: absolute; left: 20px; @@ -222,6 +331,7 @@ .stadium-shell { --stadium-radius: 24px; position: relative; + z-index: 2; width: 100%; min-height: calc(var(--stadium-radius) * 2.1); padding: 12px 18px; @@ -545,8 +655,8 @@ .composer-container { position: relative; - height: var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); - flex: 0 0 var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); + height: var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px))); + flex: 0 0 var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px))); transition: transform 0.3s ease; z-index: 2; }