fix(runtime-queue): stabilize manual send and composer growth
This commit is contained in:
parent
aa3b9bbfe3
commit
209dbf5532
@ -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,
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
340
server/tasks.py
340
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/<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 [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user