fix(runtime-queue): stabilize manual send and composer growth

This commit is contained in:
JOJO 2026-05-07 12:04:50 +08:00
parent aa3b9bbfe3
commit 209dbf5532
15 changed files with 1702 additions and 63 deletions

View File

@ -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,
})

View File

@ -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}

View File

@ -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/<task_id>/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/<task_id>/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/<task_id>/runtime_queue/<message_id>", 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/<task_id>/runtime_queue/<message_id>/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 [],
},
}
)

View File

@ -121,6 +121,7 @@
'chat-container--monitor': chatDisplayMode === 'monitor',
'has-title-ribbon': titleRibbonVisible
}"
:style="chatContainerStyle"
>
<TokenDrawer
:visible="Boolean(currentConversationId)"
@ -294,6 +295,7 @@
:permission-options="permissionModeOptions"
:current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages"
@update:input-message="inputSetMessage"
@input-change="handleInputChange"
@input-focus="handleInputFocus"
@ -323,6 +325,9 @@
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
@open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage"
@composer-height-change="handleComposerHeightChange"
/>
</div>
</main>

View File

@ -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',

View File

@ -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 = [];

View File

@ -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;

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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) {

View File

@ -1,6 +1,38 @@
<template>
<div class="input-area compact-input-area">
<div class="input-area compact-input-area" ref="inputAreaRoot">
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
<div
class="runtime-queue-list"
:class="{ 'runtime-queue-list--empty': !runtimeQueuedMessagesForRender.length }"
ref="runtimeQueueList"
>
<div
v-for="(entry, index) in runtimeQueuedMessagesForRender"
:key="entry.id"
class="runtime-queue-item"
:class="{
'runtime-queue-item--top': index === 0
}"
:data-runtime-queue-id="entry.id || ''"
:ref="bindRuntimeQueueItemRef(entry.id)"
>
<span class="runtime-queue-item__text">{{ entry.text || '' }}</span>
<button
type="button"
class="runtime-queue-item__action"
@click.stop="$emit('guide-runtime-message', entry.id)"
>
引导
</button>
<button
type="button"
class="runtime-queue-item__action runtime-queue-item__action--danger"
@click.stop="$emit('delete-runtime-message', entry.id)"
>
删除
</button>
</div>
</div>
<div
class="stadium-shell"
ref="compactInputShell"
@ -65,7 +97,7 @@
class="stadium-input"
rows="1"
:value="inputMessage"
:disabled="!isConnected || streamingMessage || inputLocked"
:disabled="!isConnected || inputLocked"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
@ -73,23 +105,15 @@
@input="onInput"
@focus="$emit('input-focus')"
@blur="$emit('input-blur')"
@keydown.enter.ctrl.prevent="$emit('send-message')"
@keydown.enter.ctrl.prevent="$emit('send-or-stop')"
></textarea>
<button
type="button"
class="stadium-btn send-btn"
@click="$emit('send-or-stop')"
:disabled="
!isConnected ||
(inputLocked && !streamingMessage) ||
(mediaUploading && !streamingMessage) ||
(!(inputMessage || '').trim() &&
!selectedImages?.length &&
!selectedVideos?.length &&
!streamingMessage)
"
:disabled="sendButtonDisabled"
>
<span v-if="streamingMessage" class="stop-icon"></span>
<span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
</div>
@ -200,7 +224,16 @@
</template>
<script setup lang="ts">
import { onMounted, ref, watch, nextTick, computed } from 'vue';
import {
onMounted,
onBeforeUnmount,
onBeforeUpdate,
onUpdated,
ref,
watch,
nextTick,
computed
} from 'vue';
import QuickMenu from '@/components/input/QuickMenu.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';
@ -236,7 +269,10 @@ const emit = defineEmits([
'open-review',
'toggle-permission-menu',
'change-permission-mode',
'open-versioning-dialog'
'open-versioning-dialog',
'guide-runtime-message',
'delete-runtime-message',
'composer-height-change'
]);
const props = defineProps<{
@ -285,14 +321,18 @@ const props = defineProps<{
permissionOptions: Array<{ value: string; label: string; description: string }>;
currentContextTokens: number;
versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
}>();
const inputStore = useInputStore();
const personalizationStore = usePersonalizationStore();
const inputAreaRoot = ref<HTMLElement | null>(null);
const stadiumShellOuter = ref<HTMLElement | null>(null);
const compactInputShell = ref<HTMLElement | null>(null);
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(0);
let composerResizeObserver: ResizeObserver | null = null;
const formatImageName = (path: string): string => {
if (!path) return '';
@ -314,6 +354,322 @@ const composerInputKey = computed(
() => `${props.currentConversationId || 'new'}:${props.isConnected ? 'online' : 'offline'}`
);
const runtimeQueuedMessagesForRender = computed(() => {
const list = Array.isArray(props.runtimeQueuedMessages) ? props.runtimeQueuedMessages : [];
return list.filter((item) => item && item.id && item.text);
});
const RUNTIME_QUEUE_ANIM_DURATION = 300;
const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null);
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
const runtimeQueuePrevIds: string[] = [];
const runtimeQueueRenderedIds: string[] = [];
const runtimeQueuePrevRects = new Map<string, DOMRect>();
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
let runtimeQueueAnimationReady = false;
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
if (!id) {
return;
}
if (el instanceof HTMLElement) {
runtimeQueueItemRefs.set(id, el);
} else {
runtimeQueueItemRefs.delete(id);
}
};
const stopRuntimeQueueAnimation = (el: HTMLElement) => {
const anim = runtimeQueueActiveAnimations.get(el);
if (anim) {
try {
anim.cancel();
} catch {
// ignore
}
runtimeQueueActiveAnimations.delete(el);
}
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) {
clearTimeout(timer);
runtimeQueueFallbackTimers.delete(el);
}
el.style.removeProperty('transition');
el.style.removeProperty('transform');
};
const playRuntimeQueueTransform = (
el: HTMLElement,
fromY: number,
toY: number,
done?: () => void
) => {
if (Math.abs(fromY - toY) < 0.5) {
stopRuntimeQueueAnimation(el);
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
return;
}
stopRuntimeQueueAnimation(el);
const from = `translateY(${fromY}px)`;
const to = `translateY(${toY}px)`;
const finish = () => {
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
};
el.style.transition = 'none';
el.style.transform = from;
if (typeof el.animate === 'function') {
//
void el.offsetHeight;
const animation = el.animate([{ transform: from }, { transform: to }], {
duration: RUNTIME_QUEUE_ANIM_DURATION,
easing: RUNTIME_QUEUE_ANIM_EASING,
fill: 'both'
});
runtimeQueueActiveAnimations.set(el, animation);
animation.addEventListener(
'finish',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
finish();
},
{ once: true }
);
animation.addEventListener(
'cancel',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
},
{ once: true }
);
return;
}
requestAnimationFrame(() => {
el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
el.style.transform = to;
const timer = window.setTimeout(() => {
runtimeQueueFallbackTimers.delete(el);
finish();
}, RUNTIME_QUEUE_ANIM_DURATION + 24);
runtimeQueueFallbackTimers.set(el, timer);
});
};
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) {
return rectHeight;
}
if (el.offsetHeight > 0) {
return el.offsetHeight;
}
return 34;
};
const snapshotRuntimeQueueIds = (): string[] => {
return runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
};
const spawnRuntimeQueueLeaveGhost = (id: string) => {
const sourceEl = runtimeQueuePrevElements.get(id);
const sourceRect = runtimeQueuePrevRects.get(id);
const listEl = runtimeQueueList.value;
if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
return;
}
const existingGhost = runtimeQueueGhosts.get(id);
if (existingGhost) {
stopRuntimeQueueAnimation(existingGhost);
existingGhost.remove();
runtimeQueueGhosts.delete(id);
}
const ghost = sourceEl.cloneNode(true) as HTMLElement;
const listRect = listEl.getBoundingClientRect();
const top = sourceRect.top - listRect.top;
const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(top)}px`;
ghost.style.width = `${Math.round(width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.querySelectorAll('button').forEach((button) => {
if (button instanceof HTMLButtonElement) {
button.disabled = true;
}
});
listEl.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
const offset = resolveRuntimeQueueOffset(ghost);
playRuntimeQueueTransform(ghost, 0, offset, () => {
runtimeQueueGhosts.delete(id);
ghost.remove();
});
};
const animateRuntimeQueueEnter = (el: HTMLElement) => {
const offset = resolveRuntimeQueueOffset(el);
el.style.pointerEvents = 'none';
playRuntimeQueueTransform(el, offset, 0, () => {
el.style.removeProperty('pointer-events');
});
};
onBeforeUpdate(() => {
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueRenderedIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
runtimeQueuePrevElements.set(id, el);
});
});
onUpdated(() => {
const currentIds = snapshotRuntimeQueueIds();
if (!runtimeQueueAnimationReady) {
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueueAnimationReady = true;
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
return;
}
const previousIdSet = new Set(runtimeQueuePrevIds);
const currentIdSet = new Set(currentIds);
const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
leavingIds.forEach((id) => {
spawnRuntimeQueueLeaveGhost(id);
runtimeQueueItemRefs.delete(id);
});
currentIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
const prevRect = runtimeQueuePrevRects.get(id);
if (!prevRect || enteringIdSet.has(id)) {
animateRuntimeQueueEnter(el);
return;
}
const nextRect = el.getBoundingClientRect();
const deltaY = prevRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) {
return;
}
playRuntimeQueueTransform(el, deltaY, 0);
});
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
});
const hasComposerContent = computed(() => {
const hasText = !!(props.inputMessage || '').trim();
if (props.streamingMessage) {
return hasText;
}
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasText || hasImages || hasVideos;
});
const showStopIcon = computed(() => props.streamingMessage && !hasComposerContent.value);
const sendButtonDisabled = computed(() => {
if (!props.isConnected) {
return true;
}
if (props.streamingMessage) {
return false;
}
if (props.inputLocked) {
return true;
}
if (props.mediaUploading) {
return true;
}
return !hasComposerContent.value;
});
const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasQueue || hasImages || hasVideos || !!props.inputIsMultiline;
});
const collectComposerVisualHeight = () => {
const root = inputAreaRoot.value;
const shell = compactInputShell.value;
if (!root || !shell) {
return 0;
}
const shellRect = shell.getBoundingClientRect();
let top = shellRect.top;
let bottom = shellRect.bottom;
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty)');
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect();
top = Math.min(top, rect.top);
bottom = Math.max(bottom, rect.bottom);
});
return Math.max(0, bottom - top);
};
const emitComposerHeight = () => {
const visualHeight = collectComposerVisualHeight();
if (!visualHeight) {
return;
}
if (baselineComposerVisualHeight.value <= 0 || !hasRuntimeLayoutExpansion.value) {
baselineComposerVisualHeight.value = visualHeight;
}
const baseline = baselineComposerVisualHeight.value || visualHeight;
const growth = Math.max(0, visualHeight - baseline);
const baseReservedHeight = 80;
const reservedHeight = Math.ceil(baseReservedHeight + growth);
emit('composer-height-change', {
reservedHeight,
visualHeight,
growth
});
};
const adjustTextareaSize = () => {
const textarea = stadiumInput.value;
if (!textarea) {
@ -328,11 +684,14 @@ const adjustTextareaSize = () => {
const multiline = targetHeight > lineHeight * 1.4;
applyLineMetrics(lines, multiline);
textarea.style.height = `${targetHeight}px`;
nextTick(() => {
emitComposerHeight();
});
};
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
if (!props.isConnected || props.streamingMessage || props.inputLocked) {
if (!props.isConnected || props.inputLocked) {
target.value = props.inputMessage || '';
return;
}
@ -423,6 +782,7 @@ const contextUsageCompactText = computed(() => {
});
defineExpose({
inputAreaRoot,
stadiumShellOuter,
compactInputShell,
stadiumInput,
@ -448,8 +808,74 @@ watch(
}
);
watch(
() => props.currentConversationId,
async () => {
baselineComposerVisualHeight.value = 0;
await nextTick();
emitComposerHeight();
}
);
watch(
() => props.runtimeQueuedMessages,
async () => {
await nextTick();
emitComposerHeight();
},
{ deep: true }
);
watch(
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
async () => {
await nextTick();
emitComposerHeight();
}
);
onMounted(() => {
adjustTextareaSize();
nextTick(() => {
emitComposerHeight();
if (typeof ResizeObserver !== 'undefined') {
composerResizeObserver = new ResizeObserver(() => {
emitComposerHeight();
});
if (inputAreaRoot.value) {
composerResizeObserver.observe(inputAreaRoot.value);
}
if (stadiumShellOuter.value) {
composerResizeObserver.observe(stadiumShellOuter.value);
}
if (compactInputShell.value) {
composerResizeObserver.observe(compactInputShell.value);
}
}
});
});
onBeforeUnmount(() => {
runtimeQueueItemRefs.forEach((el) => {
if (el) {
stopRuntimeQueueAnimation(el);
}
});
runtimeQueueGhosts.forEach((ghost) => {
stopRuntimeQueueAnimation(ghost);
ghost.remove();
});
runtimeQueueGhosts.clear();
runtimeQueueItemRefs.clear();
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueAnimationReady = false;
if (composerResizeObserver) {
composerResizeObserver.disconnect();
composerResizeObserver = null;
}
});
</script>
@ -503,5 +929,4 @@ onMounted(() => {
.image-remove-btn-hover:hover {
color: #ef4444;
}
</style>

View File

@ -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 = '';
}
}
});

View File

@ -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 {

View File

@ -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;
}