Compare commits

..

3 Commits

11 changed files with 71 additions and 16 deletions

View File

@ -79,7 +79,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
@ -100,7 +100,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
} }
} else if let Some(session_id) = response.metadata.get("session_id") { } else if let Some(session_id) = response.metadata.get("session_id") {
@ -140,7 +140,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
} }
} else if let Some(sessions_json) = response.metadata.get("sessions") { } else if let Some(sessions_json) = response.metadata.get("sessions") {
@ -159,7 +159,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
@ -179,7 +179,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
} }
} else { } else {
@ -189,7 +189,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
} }
} }
} }
@ -203,7 +203,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None, user_message_id: None,
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -258,7 +258,9 @@ impl AgentExecutionService {
}; };
let result = agent.process(history, Some(&system_prompt_context)).await?; let result = agent.process(history, Some(&system_prompt_context)).await?;
let metadata = HashMap::new(); let mut metadata = HashMap::new();
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配
metadata.insert("user_message_id".to_string(), user_message.id.clone());
self.finalize_result_and_schedule_compaction( self.finalize_result_and_schedule_compaction(
request.session.clone(), request.session.clone(),

View File

@ -200,7 +200,7 @@ impl BusToolCallEmitter {
priority: "medium".to_string(), priority: "medium".to_string(),
created_at: now + idx as i64, created_at: now + idx as i64,
updated_at: now, updated_at: now,
created_by_message_id: Some(message.id.clone()), created_by_message_id: message.tool_call_id.clone(),
}) })
}) })
.collect(); .collect();

View File

@ -21,6 +21,7 @@ use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler; use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler; use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
@ -411,6 +412,12 @@ async fn handle_inbound(
state.task_repository.clone(), state.task_repository.clone(),
system_prompt_provider.clone(), system_prompt_provider.clone(),
))); )));
// 注册 save_topic 处理器
router.register(Box::new(SaveTopicCommandHandler::new(
store.clone(),
state.task_repository.clone(),
system_prompt_provider.clone(),
).with_session_manager(state.session_manager.clone())));
// 注册 delete_topic 处理器 // 注册 delete_topic 处理器
router.register(Box::new( router.register(Box::new(
DeleteTopicCommandHandler::new(store.clone()) DeleteTopicCommandHandler::new(store.clone())
@ -857,6 +864,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
topic_id: None, topic_id: None,
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: msg.reasoning_content.clone(), reasoning_content: msg.reasoning_content.clone(),
user_message_id: None,
}); });
} }
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复 // AssistantResponse 已携带 reasoning 时ToolCall 不再重复
@ -873,6 +881,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
topic_id: None, topic_id: None,
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: tc_reasoning.clone(), reasoning_content: tc_reasoning.clone(),
user_message_id: None,
}); });
} }
outbound outbound
@ -887,6 +896,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
topic_id: None, topic_id: None,
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: msg.reasoning_content.clone(), reasoning_content: msg.reasoning_content.clone(),
user_message_id: None,
}] }]
} }
} }
@ -926,6 +936,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
topic_id: None, topic_id: None,
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: None, reasoning_content: None,
user_message_id: None,
}], }],
_ => Vec::new(), _ => Vec::new(),
} }

View File

@ -150,6 +150,8 @@ pub enum WsOutbound {
timestamp: Option<i64>, timestamp: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>, reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_message_id: Option<String>,
}, },
#[serde(rename = "tool_call")] #[serde(rename = "tool_call")]
ToolCall { ToolCall {
@ -167,6 +169,8 @@ pub enum WsOutbound {
timestamp: Option<i64>, timestamp: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>, reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_message_id: Option<String>,
}, },
#[serde(rename = "tool_result")] #[serde(rename = "tool_result")]
ToolResult { ToolResult {
@ -280,6 +284,8 @@ pub enum WsOutbound {
subagent_task_id: Option<String>, subagent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>, topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_message_id: Option<String>,
}, },
#[serde(rename = "stream_end")] #[serde(rename = "stream_end")]
StreamEnd { StreamEnd {

View File

@ -112,6 +112,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: message.metadata.get("user_message_id").cloned(),
}] }]
} }
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall { OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
@ -131,6 +132,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: message.metadata.get("user_message_id").cloned(),
}], }],
OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult { OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult {
id: message id: message
@ -182,6 +184,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
reasoning_delta: message.reasoning_content.clone(), reasoning_delta: message.reasoning_content.clone(),
subagent_task_id: message.metadata.get("subagent_task_id").cloned(), subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
user_message_id: message.metadata.get("user_message_id").cloned(),
}], }],
OutboundEventKind::StreamEnd => vec![WsOutbound::StreamEnd { OutboundEventKind::StreamEnd => vec![WsOutbound::StreamEnd {
id: message.tool_call_id.clone().unwrap_or_default(), id: message.tool_call_id.clone().unwrap_or_default(),

View File

@ -235,7 +235,7 @@ impl SubAgentEmitter {
priority: "medium".to_string(), priority: "medium".to_string(),
created_at: now + idx as i64, created_at: now + idx as i64,
updated_at: now, updated_at: now,
created_by_message_id: Some(message.id.clone()), created_by_message_id: message.tool_call_id.clone(),
}) })
}) })
.collect(); .collect();

View File

@ -401,11 +401,12 @@ function App() {
// 点击待办项后滚动到对应消息 // 点击待办项后滚动到对应消息
const handleTodoClick = useCallback((todo: TodoItemSummary) => { const handleTodoClick = useCallback((todo: TodoItemSummary) => {
// 直接使用后端返回的 created_by_message_id
if (todo.created_by_message_id) { if (todo.created_by_message_id) {
setHighlightedMessageId(todo.created_by_message_id) // 先清再设,确保同一 todo 重复点击也能触发 useEffect
setHighlightedMessageId(null)
const msgId = todo.created_by_message_id
setTimeout(() => setHighlightedMessageId(msgId), 0)
} else { } else {
// 如果消息 ID 不存在(旧数据),给出友好提示
alert('该待办的完成记录无法定位,可能是历史数据') alert('该待办的完成记录无法定位,可能是历史数据')
} }
}, [setHighlightedMessageId]) }, [setHighlightedMessageId])
@ -485,7 +486,12 @@ function App() {
} }
} }
return result // 过滤无实质内容的 merged_tool无结果且非等待中
return result.filter(msg => {
if (msg.type !== 'merged_tool') return true
if (msg.status === 'pending') return true
return !!(msg.resultContent && msg.resultContent.trim())
})
}, [messages]) }, [messages])
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置 // 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置

View File

@ -606,8 +606,9 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
) )
} }
// 隐藏思考且无实质内容时,不渲染空的助手消息气泡 // 隐藏无可见内容的助手消息(无文本,且无思考或思考被关闭)
if (!isUser && !isTool && !isMergedTool && !showThinking && !message.content.trim() && message.reasoningContent) { const hasVisibleContent = !!(message.content && message.content.trim()) || (showThinking && message.reasoningContent)
if (!isUser && !isTool && !isMergedTool && !hasVisibleContent) {
return null return null
} }

View File

@ -167,6 +167,9 @@ export function useChat(): UseChatReturn {
const [channels, setChannels] = useState<Channel[]>([]) const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('websocket') const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
// Track user message IDs already synced from backend to avoid duplicate updates
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set())
// Message ID generator // Message ID generator
const messageIdCounter = useRef(0) const messageIdCounter = useRef(0)
const generateMessageId = () => { const generateMessageId = () => {
@ -356,6 +359,23 @@ export function useChat(): UseChatReturn {
} }
} }
// Sync backend user message ID to the last local user message,
// so that created_by_message_id (backend UUID) can match DOM data-message-id
const applyUserMessageId = useCallback((userMessageId: string) => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return
syncedUserMessageIdsRef.current.add(userMessageId)
setMessages(prev => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'user') {
const updated = [...prev]
updated[i] = { ...updated[i], id: userMessageId }
return updated
}
}
return prev
})
}, [])
const handleServerMessage = useCallback((message: WsOutbound) => { const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message) console.log('Received message:', message)
@ -639,6 +659,7 @@ export function useChat(): UseChatReturn {
] ]
}) })
setIsLoading(false) setIsLoading(false)
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break break
} }
@ -678,6 +699,7 @@ export function useChat(): UseChatReturn {
if (currentTopic && !currentTopic.description) { if (currentTopic && !currentTopic.description) {
setTopicRefreshTrigger(n => n + 1) setTopicRefreshTrigger(n => n + 1)
} }
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break break
} }
@ -700,6 +722,7 @@ export function useChat(): UseChatReturn {
reasoningContent: msg.reasoning_content, reasoningContent: msg.reasoning_content,
}, },
]) ])
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break break
} }

View File

@ -46,6 +46,7 @@ export interface AssistantResponse {
topic_id?: string topic_id?: string
timestamp?: number timestamp?: number
reasoning_content?: string reasoning_content?: string
user_message_id?: string
} }
export interface ToolCall { export interface ToolCall {
@ -60,6 +61,7 @@ export interface ToolCall {
topic_id?: string topic_id?: string
timestamp?: number timestamp?: number
reasoning_content?: string reasoning_content?: string
user_message_id?: string
} }
export interface ToolResult { export interface ToolResult {
@ -266,6 +268,7 @@ export interface StreamDelta {
reasoning_delta?: string reasoning_delta?: string
subagent_task_id?: string subagent_task_id?: string
topic_id?: string topic_id?: string
user_message_id?: string
} }
export interface StreamEnd { export interface StreamEnd {