From 32865f6d6eb2e1d192451fdf0c6f06c3318008a3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 30 May 2026 16:30:05 +0800 Subject: [PATCH] fix(ui): refine message visibility and goal approval flow --- server/chat_flow_task_main.py | 104 +++++++++++++++--- server/chat_flow_task_support.py | 32 ++++++ server/goal_flow.py | 4 +- static/src/app/methods/taskPolling.ts | 34 +++++- static/src/components/chat/ChatArea.vue | 25 ++--- .../components/panels/ToolApprovalPanel.vue | 24 +++- static/src/composables/useLegacySocket.ts | 34 +++++- static/src/stores/chat.ts | 13 ++- .../styles/components/chat/_chat-area.scss | 15 +++ static/src/utils/messageVisibility.ts | 79 +++++++++++++ 10 files changed, 320 insertions(+), 44 deletions(-) create mode 100644 static/src/utils/messageVisibility.ts diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 2832218..d17cb41 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -141,6 +141,37 @@ from .goal_flow import ( emit_goal_progress, ) +_VALID_USER_MESSAGE_SOURCES = { + "user", + "guidance", + "notify", + "presend", + "sub_agent", + "background_command", + "goal", + "goal_prompt", + "goal_review", + "compression", + "permission", + "sandbox", + "skill", +} + + +def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = False) -> Dict[str, Any]: + normalized = str(source or "").strip().lower() + if normalized in {"skill", "goal_prompt", "permission", "sandbox"}: + return {"visibility": "hidden", "starts_work": False} + if normalized in {"guidance", "notify"}: + return {"visibility": "compact", "starts_work": False} + if normalized in {"goal_review", "compression", "sub_agent", "background_command"}: + return {"visibility": "compact", "starts_work": True} + if normalized == "presend": + return {"visibility": "chat", "starts_work": True} + if normalized == "goal": + return {"visibility": "compact", "starts_work": True} + return {"visibility": "chat", "starts_work": normalized == "user" and not auto_user_message_event} + def _should_skip_versioning_for_message( *, @@ -308,7 +339,7 @@ async def _dispatch_completion_user_notice( message_source = "background_command" elif extra_payload.get("sub_agent_notice"): message_source = "sub_agent" - if message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command", "goal"}: + if message_source not in _VALID_USER_MESSAGE_SOURCES: message_source = "user" try: from .tasks import task_manager @@ -326,10 +357,17 @@ async def _dispatch_completion_user_notice( "model_key": getattr(web_terminal, "model_key", None), "message_source": message_source, } + ui_defaults = _user_message_ui_defaults( + message_source, + auto_user_message_event=True, + ) # 关键:通知类后台任务需要把 user_message 写入任务事件流, # 否则前端轮询只会看到 AI/tool 事件,看不到 user_message。 session_data["auto_user_message_event"] = True - session_data["auto_user_message_payload"] = dict(extra_payload or {}) + session_data["auto_user_message_payload"] = { + **dict(extra_payload or {}), + **ui_defaults, + } rec = task_manager.create_chat_task( username, workspace_id, @@ -346,8 +384,14 @@ async def _dispatch_completion_user_notice( 'conversation_id': conversation_id, 'task_id': rec.task_id, 'message_source': message_source, + **ui_defaults, } payload.update(extra_payload) + payload["metadata"] = { + "message_source": message_source, + **ui_defaults, + **(payload.get("metadata") or {}), + } sender('user_message', payload) return except Exception as e: @@ -358,7 +402,14 @@ async def _dispatch_completion_user_notice( 'conversation_id': conversation_id, 'message_source': message_source, } + payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True)) payload.update(extra_payload) + payload["metadata"] = { + "message_source": message_source, + "visibility": payload.get("visibility"), + "starts_work": payload.get("starts_work"), + **(payload.get("metadata") or {}), + } sender('user_message', payload) try: task_handle = asyncio.create_task(handle_task_with_sender( @@ -789,7 +840,8 @@ async def handle_task_with_sender( # 添加到对话历史 user_work_started_at = datetime.now().isoformat() user_message_index = -1 - user_work_finalized = False + current_work_user_message_index = -1 + finalized_work_message_indices: set[int] = set() history_len_before = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) is_first_user_message = history_len_before == 0 # 构建 user 消息来源与 metadata @@ -809,14 +861,20 @@ async def handle_task_with_sender( user_message_source = "background_command" elif auto_payload.get("sub_agent_notice"): user_message_source = "sub_agent" - if user_message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command", "goal"}: + if user_message_source not in _VALID_USER_MESSAGE_SOURCES: user_message_source = "user" # 构建user消息metadata user_message_metadata = { "message_source": user_message_source, } - if user_message_source in {"user", "presend", "sub_agent", "background_command"}: + user_message_metadata.update( + _user_message_ui_defaults( + user_message_source, + auto_user_message_event=bool(auto_user_message_event), + ) + ) + if user_message_metadata.get("starts_work") is True: user_message_metadata["work_timer"] = { "status": "working", "started_at": user_work_started_at @@ -836,6 +894,7 @@ async def handle_task_with_sender( user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1 except Exception: user_message_index = -1 + current_work_user_message_index = user_message_index skill_context_messages = getattr(web_terminal, "_skill_context_messages", None) if not auto_user_message_event and isinstance(skill_context_messages, list): for skill_item in skill_context_messages: @@ -852,6 +911,8 @@ async def handle_task_with_sender( metadata={ "source": "skill", "message_source": "skill", + "visibility": "hidden", + "starts_work": False, "hidden": True, "skill_name": skill_name, "skill_path": skill_path, @@ -867,6 +928,9 @@ async def handle_task_with_sender( "videos": (saved_user_message or {}).get("videos") or videos or [], "media_refs": (saved_user_message or {}).get("media_refs") or [], "message_source": user_message_source, + "visibility": user_message_metadata.get("visibility"), + "starts_work": user_message_metadata.get("starts_work"), + "metadata": user_message_metadata, "conversation_id": conversation_id, }, ) @@ -880,14 +944,14 @@ async def handle_task_with_sender( auto_user_message_event=bool(auto_user_message_event), ) - def finalize_user_work_timer(): - nonlocal user_work_finalized - if user_work_finalized: + def finalize_user_work_timer(index: Optional[int] = None): + target_index = current_work_user_message_index if index is None else index + if target_index in finalized_work_message_indices: return history = getattr(web_terminal.context_manager, "conversation_history", None) or [] - if user_message_index < 0 or user_message_index >= len(history): + if target_index < 0 or target_index >= len(history): return - target_msg = history[user_message_index] or {} + target_msg = history[target_index] or {} if target_msg.get("role") != "user": return metadata = target_msg.get("metadata") or {} @@ -910,9 +974,20 @@ async def handle_task_with_sender( }) metadata["work_timer"] = timer target_msg["metadata"] = metadata - history[user_message_index] = target_msg + history[target_index] = target_msg web_terminal.context_manager.auto_save_conversation(force=True) - user_work_finalized = True + finalized_work_message_indices.add(target_index) + + def switch_current_work_timer_to_latest_user(): + nonlocal current_work_user_message_index + history = getattr(web_terminal.context_manager, "conversation_history", None) or [] + for idx in range(len(history) - 1, -1, -1): + msg = history[idx] or {} + if msg.get("role") == "user": + metadata = msg.get("metadata") or {} + if isinstance(metadata.get("work_timer"), dict): + current_work_user_message_index = idx + return versioning_checkpoint_recorded = False @@ -1394,7 +1469,10 @@ async def handle_task_with_sender( ) action = goal_result.get("action") if action == "continue": - # 已注入续命 user 消息,重置目标轮段标志并继续主循环 + # 已注入续命 user 消息:上一段 assistant work 到此结束, + # 后续计时切换到刚注入的 goal_review 消息。 + finalize_user_work_timer() + switch_current_work_timer_to_latest_user() goal_segment_made_tool_call = False is_first_iteration = False debug_log(f"[Goal] 续命:{goal_result.get('message', '')[:80]}") diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 0cc9109..ab16d07 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import time +from datetime import datetime from typing import Any, Callable, Dict, List, Optional from modules.sub_agent_manager import TERMINAL_STATUSES @@ -15,9 +16,31 @@ _VALID_SOURCES = { "sub_agent", "background_command", "goal", + "goal_prompt", + "goal_review", + "compression", + "permission", + "sandbox", + "skill", } +def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str, Any]: + """Return UI metadata for model-facing user messages injected by the runtime.""" + normalized = str(src or "").strip().lower() + if normalized in {"skill", "goal_prompt", "permission", "sandbox"}: + return {"visibility": "hidden", "starts_work": False} + if normalized in {"guidance", "notify"}: + return {"visibility": "compact", "starts_work": False} + if normalized in {"goal_review", "compression", "sub_agent", "background_command", "presend"}: + return {"visibility": "compact", "starts_work": True} + if normalized == "goal": + # Backward-compatible fallback for old callers. Inline goal injections are + # current-segment hints; non-inline goal injections start the next segment. + return {"visibility": "compact", "starts_work": not bool(inline)} + return {"visibility": "chat", "starts_work": normalized == "user"} + + def inject_runtime_user_message( *, web_terminal, @@ -57,8 +80,14 @@ def inject_runtime_user_message( "runtime_guidance": True, "runtime_guidance_original": raw, } + metadata.update(_runtime_message_ui_defaults(src, inline=inline)) if extra_metadata: metadata.update({k: v for k, v in extra_metadata.items() if v is not None}) + if metadata.get("starts_work") is True and not isinstance(metadata.get("work_timer"), dict): + metadata["work_timer"] = { + "status": "working", + "started_at": datetime.now().isoformat(), + } if persist: try: @@ -93,6 +122,9 @@ def inject_runtime_user_message( "inline": inline, "source": src, "message_source": src, + "visibility": metadata.get("visibility"), + "starts_work": metadata.get("starts_work"), + "metadata": metadata, "runtime_guidance": True, "runtime_guidance_original": raw, } diff --git a/server/goal_flow.py b/server/goal_flow.py index cc49d8c..d35f4a1 100644 --- a/server/goal_flow.py +++ b/server/goal_flow.py @@ -97,7 +97,7 @@ def inject_goal_prompt( web_terminal=web_terminal, messages=messages, text=GOAL_MODE_PROMPT, - source="goal", + source="goal_prompt", sender=sender, conversation_id=conversation_id, inline=False, @@ -231,7 +231,7 @@ async def handle_goal_after_turn( web_terminal=web_terminal, messages=messages, text=f"{CONTINUE_PREFIX}{message}", - source="goal", + source="goal_review", sender=sender, conversation_id=conversation_id, inline=False, diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index ece876a..8453bab 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { debugLog } from './common'; import { useTaskStore } from '../../stores/task'; +import { getMessageVisibility, messageStartsWork } from '../../utils/messageVisibility'; const debugNotifyLog = (...args: any[]) => { void args; @@ -114,6 +115,23 @@ function resolveUserMessageSource(data: any): string { return 'user'; } +function resolveUserMessageMetadata(data: any, source: string, message: string): Record { + const base = data && typeof data.metadata === 'object' && data.metadata ? { ...data.metadata } : {}; + const metadata: Record = { + ...base, + message_source: source + }; + if (data && Object.prototype.hasOwnProperty.call(data, 'visibility')) { + metadata.visibility = data.visibility; + } + if (data && Object.prototype.hasOwnProperty.call(data, 'starts_work')) { + metadata.starts_work = data.starts_work; + } + metadata.visibility = getMessageVisibility({ role: 'user', content: message, metadata, ...data }); + metadata.starts_work = messageStartsWork({ role: 'user', content: message, metadata, ...data }); + return metadata; +} + function isEmptyAssistantPlaceholderMessage(message: any): boolean { if (!message || message.role !== 'assistant') { return false; @@ -1912,10 +1930,6 @@ export const taskPollingMethods = { this.autoApprovalFeedLines.push(String(progress.message)); } this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20); - this.rightCollapsed = false; - if (this.rightWidth < this.minPanelWidth) { - this.rightWidth = this.minPanelWidth; - } this.$forceUpdate(); }, @@ -2044,6 +2058,7 @@ export const taskPollingMethods = { const isAutoUserMessage = isSystemAutoUserMessagePayload(data); const isRuntimeModeNotice = isRuntimeModeNoticePayload(data); const source = resolveUserMessageSource(data); + const eventMetadata = resolveUserMessageMetadata(data, source, message); const incomingImages = Array.isArray(data?.images) ? data.images : []; const incomingVideos = Array.isArray(data?.videos) ? data.videos : []; const incomingMediaRefs = Array.isArray(data?.media_refs) @@ -2072,6 +2087,7 @@ export const taskPollingMethods = { target.media_refs = incomingMediaRefs; target.metadata = { ...(target.metadata || {}), + ...eventMetadata, media_refs: incomingMediaRefs }; } else { @@ -2080,7 +2096,8 @@ export const taskPollingMethods = { incomingImages, incomingVideos, incomingMediaRefs, - source + source, + eventMetadata ); } this.taskInProgress = true; @@ -2099,6 +2116,9 @@ export const taskPollingMethods = { messagePreview: message.slice(0, 80) }); } else { + if (eventMetadata.starts_work === true) { + this.markLatestUserWorkCompleted(); + } const shouldRestoreWaitingPlaceholder = this.moveTrailingEmptyAssistantPlaceholderAfterUserInsert('auto_user_message') && (this.taskInProgress || this.streamingMessage); @@ -2113,6 +2133,7 @@ export const taskPollingMethods = { recentMatch.media_refs = incomingMediaRefs; recentMatch.metadata = { ...(recentMatch.metadata || {}), + ...eventMetadata, media_refs: incomingMediaRefs, message_source: source }; @@ -2122,7 +2143,8 @@ export const taskPollingMethods = { incomingImages, incomingVideos, incomingMediaRefs, - source + source, + eventMetadata ); } if (shouldRestoreWaitingPlaceholder) { diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 44113fa..35c393e 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -6,11 +6,12 @@ :key="index" class="message-block" :class="{ + 'message-block--compact-user': getMessageVisibility(msg) === 'compact', 'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg), 'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index) }" > -
+
{{ userHeaderLabel(msg) }} @@ -596,6 +597,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue'; import StackedBlocks from './StackedBlocks.vue'; import MinimalBlocks from './MinimalBlocks.vue'; import { usePersonalizationStore } from '@/stores/personalization'; +import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility'; const props = defineProps<{ messages: Array; @@ -677,15 +679,11 @@ function renderUserMessageContent(content: string): string { html += escapeUserHtml(source.slice(cursor)); return html; } -const isHiddenSkillMessage = (message: any) => { +const isHiddenUserMessage = (message: any) => { if (!message || message.role !== 'user') { return false; } - const meta = message.metadata || {}; - const source = String(meta.source || meta.message_source || '') - .trim() - .toLowerCase(); - return source === 'skill' || meta.hidden === true; + return getMessageVisibility(message) === 'hidden'; }; const isEmptyAssistantMessage = (message: any) => { if (!message || message.role !== 'assistant') { @@ -720,7 +718,7 @@ const filteredMessages = computed(() => { if (m?.role === 'system') { return false; } - if (isHiddenSkillMessage(m)) { + if (isHiddenUserMessage(m)) { return false; } if (isEmptyAssistantMessage(m)) { @@ -1353,18 +1351,15 @@ function findAssistantHeaderAnchorUser(index: number): any | null { if (!prev || prev.role !== 'user') { return null; } - // assistant 头部属于“紧挨着它上方的一组连续 user 消息”。运行期自动插入 - // guidance/goal 等 user 消息时,头部仍要显示在整组 user 消息下方,并沿用 - // 这组里真正触发工作的那条 user 消息的计时器。 + // assistant 头部属于“紧挨着它上方的一组连续 user-side 消息”。 + // 只有 starts_work=true 的消息才会开启新 work segment;运行期 guidance + // 只是当前 segment 内的补充,不会重置头部与计时器。 for (let i = index - 1; i >= 0; i -= 1) { const item = filteredMessages.value[i]; if (!item || item.role !== 'user') { break; } - const source = String(item?.metadata?.message_source || 'user') - .trim() - .toLowerCase(); - if (['user', 'presend', 'sub_agent', 'background_command'].includes(source)) { + if (messageStartsWork(item)) { return item; } } diff --git a/static/src/components/panels/ToolApprovalPanel.vue b/static/src/components/panels/ToolApprovalPanel.vue index a73aa0d..c87df6e 100644 --- a/static/src/components/panels/ToolApprovalPanel.vue +++ b/static/src/components/panels/ToolApprovalPanel.vue @@ -1,7 +1,7 @@