Compare commits
No commits in common. "d1d4998a265f431aabadf792802d1fb26ede0f1e" and "e8a3a47ac7d93d003c9a8cb4777d7ed3a6076cb1" have entirely different histories.
d1d4998a26
...
e8a3a47ac7
@ -853,6 +853,121 @@ impl AgentLoop {
|
|||||||
&self.tools
|
&self.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize message history by removing assistant messages with tool_calls
|
||||||
|
/// that don't have corresponding tool result messages, at ANY position in
|
||||||
|
/// the history (not just trailing).
|
||||||
|
///
|
||||||
|
/// Incomplete sequences can appear in the middle of history when:
|
||||||
|
/// 1. The process was interrupted mid-execution (before commit cb58d9f),
|
||||||
|
/// then a new user message was appended, burying the orphan.
|
||||||
|
/// 2. History compaction preserves orphaned tool_calls from a pre-fix era
|
||||||
|
/// or from a race condition between persistence and snapshot.
|
||||||
|
///
|
||||||
|
/// Sending such incomplete sequences to the API causes errors like
|
||||||
|
/// "insufficient tool messages following tool_calls message".
|
||||||
|
///
|
||||||
|
/// Returns the number of messages removed.
|
||||||
|
fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut removed = 0;
|
||||||
|
|
||||||
|
// Phase 1: Single reverse pass to find ALL assistant messages with
|
||||||
|
// incomplete tool_calls, regardless of position.
|
||||||
|
//
|
||||||
|
// Scanning right-to-left means we encounter tool results before their
|
||||||
|
// parent assistants, so we naturally know which tool_call_ids have
|
||||||
|
// corresponding results.
|
||||||
|
let mut resolved_ids: HashSet<String> = HashSet::new();
|
||||||
|
let mut with_parent: HashSet<String> = HashSet::new();
|
||||||
|
let mut remove_indices: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
|
// Phase 1: Reverse pass — collect tool result IDs first, then
|
||||||
|
// validate each assistant's tool_calls against already-seen results.
|
||||||
|
//
|
||||||
|
// Because we scan right-to-left, any tool result we've already seen
|
||||||
|
// appears AFTER the current message in forward order. This correctly
|
||||||
|
// identifies which assistant tool_calls have corresponding results.
|
||||||
|
for i in (0..messages.len()).rev() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
|
||||||
|
if msg.role == "tool" {
|
||||||
|
if let Some(ref tc_id) = msg.tool_call_id {
|
||||||
|
resolved_ids.insert(tc_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.role == "assistant"
|
||||||
|
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
||||||
|
{
|
||||||
|
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||||||
|
let all_have_results = tool_calls
|
||||||
|
.iter()
|
||||||
|
.all(|tc| resolved_ids.contains(&tc.id));
|
||||||
|
|
||||||
|
if all_have_results {
|
||||||
|
for tc in tool_calls.iter() {
|
||||||
|
with_parent.insert(tc.id.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let missing_count = tool_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|tc| !resolved_ids.contains(&tc.id))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
tool_call_count = tool_calls.len(),
|
||||||
|
missing_tool_results = missing_count,
|
||||||
|
message_id = %msg.id,
|
||||||
|
message_index = i,
|
||||||
|
"Removing assistant message with incomplete tool call sequence — \
|
||||||
|
tool results were never persisted (likely due to process interruption \
|
||||||
|
or history compaction preserving an orphan)"
|
||||||
|
);
|
||||||
|
|
||||||
|
remove_indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove in descending index order to avoid shifting
|
||||||
|
for &idx in &remove_indices {
|
||||||
|
messages.remove(idx);
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
|
||||||
|
// trailing ones). A tool message is orphaned if its tool_call_id has no
|
||||||
|
// matching parent assistant remaining in the history.
|
||||||
|
if !with_parent.is_empty() || !resolved_ids.is_empty() {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < messages.len() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
if msg.role == "tool" {
|
||||||
|
let is_orphaned = match &msg.tool_call_id {
|
||||||
|
Some(tc_id) => !with_parent.contains(tc_id),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if is_orphaned {
|
||||||
|
tracing::warn!(
|
||||||
|
tool_call_id = ?msg.tool_call_id,
|
||||||
|
message_id = %msg.id,
|
||||||
|
message_index = i,
|
||||||
|
"Removing orphaned tool result message — its parent assistant \
|
||||||
|
tool_calls message was removed or never persisted"
|
||||||
|
);
|
||||||
|
messages.remove(i);
|
||||||
|
removed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a message using the provided conversation history.
|
/// Process a message using the provided conversation history.
|
||||||
/// History management is handled externally by SessionManager.
|
/// History management is handled externally by SessionManager.
|
||||||
///
|
///
|
||||||
@ -878,7 +993,7 @@ impl AgentLoop {
|
|||||||
|
|
||||||
// Sanitize: remove any trailing incomplete tool call sequences
|
// Sanitize: remove any trailing incomplete tool call sequences
|
||||||
// that may have been persisted before a process interruption.
|
// that may have been persisted before a process interruption.
|
||||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
Self::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
|
|
||||||
// Track tool calls for loop detection
|
// Track tool calls for loop detection
|
||||||
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
||||||
@ -1448,8 +1563,8 @@ mod tests {
|
|||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
model_extra: std::collections::HashMap::new(),
|
model_extra: std::collections::HashMap::new(),
|
||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
}
|
}
|
||||||
@ -1554,9 +1669,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_truncate_tool_result_handles_utf8_char_boundaries() {
|
fn test_truncate_tool_result_handles_utf8_char_boundaries() {
|
||||||
let input = "范".repeat(100_500);
|
let input = "范".repeat(20_500);
|
||||||
|
|
||||||
let output = truncate_tool_result(&input, 100_000);
|
let output = truncate_tool_result(&input, 20_000);
|
||||||
|
|
||||||
assert!(output.contains("Output truncated"));
|
assert!(output.contains("Output truncated"));
|
||||||
assert!(output.is_char_boundary(output.len()));
|
assert!(output.is_char_boundary(output.len()));
|
||||||
@ -1821,7 +1936,7 @@ mod tests {
|
|||||||
// Tool result for call_1 is MISSING — incomplete sequence
|
// Tool result for call_1 is MISSING — incomplete sequence
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].role, "user");
|
assert_eq!(messages[0].role, "user");
|
||||||
@ -1842,7 +1957,7 @@ mod tests {
|
|||||||
ChatMessage::tool("call_1", "calculator", "2"),
|
ChatMessage::tool("call_1", "calculator", "2"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 0);
|
assert_eq!(removed, 0);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
}
|
}
|
||||||
@ -1872,7 +1987,7 @@ mod tests {
|
|||||||
// Also missing tool result for call_2
|
// Also missing tool result for call_2
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// Should remove both trailing assistant messages with incomplete tool calls
|
// Should remove both trailing assistant messages with incomplete tool calls
|
||||||
assert_eq!(removed, 2);
|
assert_eq!(removed, 2);
|
||||||
assert_eq!(messages.len(), 2);
|
assert_eq!(messages.len(), 2);
|
||||||
@ -1906,7 +2021,7 @@ mod tests {
|
|||||||
// Missing tool result for call_2
|
// Missing tool result for call_2
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed_count = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// Phase 1 removes the assistant message (call_2 has no result).
|
// Phase 1 removes the assistant message (call_2 has no result).
|
||||||
// Phase 2 removes the orphaned tool result for call_1 (its parent
|
// Phase 2 removes the orphaned tool result for call_1 (its parent
|
||||||
// assistant was removed).
|
// assistant was removed).
|
||||||
@ -1923,7 +2038,7 @@ mod tests {
|
|||||||
ChatMessage::user("how are you"),
|
ChatMessage::user("how are you"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 0);
|
assert_eq!(removed, 0);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
}
|
}
|
||||||
@ -1931,7 +2046,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_sanitize_handles_empty_messages() {
|
fn test_sanitize_handles_empty_messages() {
|
||||||
let mut messages: Vec<ChatMessage> = vec![];
|
let mut messages: Vec<ChatMessage> = vec![];
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 0);
|
assert_eq!(removed, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1943,7 +2058,7 @@ mod tests {
|
|||||||
ChatMessage::tool("call_1", "calculator", "2"),
|
ChatMessage::tool("call_1", "calculator", "2"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
assert!(messages.is_empty());
|
assert!(messages.is_empty());
|
||||||
}
|
}
|
||||||
@ -1971,7 +2086,7 @@ mod tests {
|
|||||||
ChatMessage::tool("call_2", "read", "contents of README"),
|
ChatMessage::tool("call_2", "read", "contents of README"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 0);
|
assert_eq!(removed, 0);
|
||||||
assert_eq!(messages.len(), 4);
|
assert_eq!(messages.len(), 4);
|
||||||
}
|
}
|
||||||
@ -2004,7 +2119,7 @@ mod tests {
|
|||||||
// Missing tool result for call_2 — only THIS sequence should be trimmed
|
// Missing tool result for call_2 — only THIS sequence should be trimmed
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
// First complete sequence preserved (5 messages), user message for second
|
// First complete sequence preserved (5 messages), user message for second
|
||||||
// question preserved
|
// question preserved
|
||||||
@ -2042,7 +2157,7 @@ mod tests {
|
|||||||
ChatMessage::tool("call_2", "read", "file contents"),
|
ChatMessage::tool("call_2", "read", "file contents"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// call_1 assistant removed (1), rest preserved (4)
|
// call_1 assistant removed (1), rest preserved (4)
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
assert_eq!(messages.len(), 4);
|
assert_eq!(messages.len(), 4);
|
||||||
@ -2082,7 +2197,7 @@ mod tests {
|
|||||||
ChatMessage::user("third"),
|
ChatMessage::user("third"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 2);
|
assert_eq!(removed, 2);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
assert_eq!(messages[0].content, "first");
|
assert_eq!(messages[0].content, "first");
|
||||||
@ -2127,7 +2242,7 @@ mod tests {
|
|||||||
ChatMessage::tool("call_valid", "good_tool", "valid result"),
|
ChatMessage::tool("call_valid", "good_tool", "valid result"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// call_has_result/call_no_result assistant (1) + its orphaned tool result (1) = 2 removed
|
// call_has_result/call_no_result assistant (1) + its orphaned tool result (1) = 2 removed
|
||||||
assert_eq!(removed, 2);
|
assert_eq!(removed, 2);
|
||||||
assert_eq!(messages.len(), 4);
|
assert_eq!(messages.len(), 4);
|
||||||
@ -2189,7 +2304,7 @@ mod tests {
|
|||||||
ChatMessage::assistant("task 3 is done"),
|
ChatMessage::assistant("task 3 is done"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// Removed: assistant with t2_call_1/t2_call_2 (1 message)
|
// Removed: assistant with t2_call_1/t2_call_2 (1 message)
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
// Original 10 messages - 1 = 9
|
// Original 10 messages - 1 = 9
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::domain::messages::ToolCall;
|
use crate::domain::messages::ToolCall;
|
||||||
|
|
||||||
@ -237,123 +237,6 @@ impl ChatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Message sanitization
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Sanitize message history by removing assistant messages with `tool_calls`
|
|
||||||
/// that don't have corresponding tool result messages, at ANY position in
|
|
||||||
/// the history (not just trailing).
|
|
||||||
///
|
|
||||||
/// Incomplete sequences can appear in the middle of history when:
|
|
||||||
/// 1. The process was interrupted mid-execution, then a new user message
|
|
||||||
/// was appended, burying the orphan.
|
|
||||||
/// 2. History compaction preserves orphaned `tool_calls` from a pre-fix era
|
|
||||||
/// or from a race condition between persistence and snapshot.
|
|
||||||
///
|
|
||||||
/// Sending such incomplete sequences to the API causes errors like
|
|
||||||
/// "insufficient tool messages following tool_calls message".
|
|
||||||
///
|
|
||||||
/// Returns the number of messages removed.
|
|
||||||
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
|
|
||||||
let mut removed = 0;
|
|
||||||
|
|
||||||
// Phase 1: Single reverse pass to find ALL assistant messages with
|
|
||||||
// incomplete tool_calls, regardless of position.
|
|
||||||
//
|
|
||||||
// Scanning right-to-left means we encounter tool results before their
|
|
||||||
// parent assistants, so we naturally know which tool_call_ids have
|
|
||||||
// corresponding results.
|
|
||||||
let mut resolved_ids: HashSet<String> = HashSet::new();
|
|
||||||
let mut with_parent: HashSet<String> = HashSet::new();
|
|
||||||
let mut remove_indices: Vec<usize> = Vec::new();
|
|
||||||
|
|
||||||
// Reverse pass — collect tool result IDs first, then validate each
|
|
||||||
// assistant's tool_calls against already-seen results.
|
|
||||||
//
|
|
||||||
// Because we scan right-to-left, any tool result we've already seen
|
|
||||||
// appears AFTER the current message in forward order. This correctly
|
|
||||||
// identifies which assistant tool_calls have corresponding results.
|
|
||||||
for i in (0..messages.len()).rev() {
|
|
||||||
let msg = &messages[i];
|
|
||||||
|
|
||||||
if msg.role == "tool" {
|
|
||||||
if let Some(ref tc_id) = msg.tool_call_id {
|
|
||||||
resolved_ids.insert(tc_id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.role == "assistant"
|
|
||||||
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
|
||||||
{
|
|
||||||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
|
||||||
let all_have_results = tool_calls
|
|
||||||
.iter()
|
|
||||||
.all(|tc| resolved_ids.contains(&tc.id));
|
|
||||||
|
|
||||||
if all_have_results {
|
|
||||||
for tc in tool_calls.iter() {
|
|
||||||
with_parent.insert(tc.id.clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let missing_count = tool_calls
|
|
||||||
.iter()
|
|
||||||
.filter(|tc| !resolved_ids.contains(&tc.id))
|
|
||||||
.count();
|
|
||||||
|
|
||||||
tracing::warn!(
|
|
||||||
tool_call_count = tool_calls.len(),
|
|
||||||
missing_tool_results = missing_count,
|
|
||||||
message_id = %msg.id,
|
|
||||||
message_index = i,
|
|
||||||
"Removing assistant message with incomplete tool call sequence — \
|
|
||||||
tool results were never persisted (likely due to process interruption \
|
|
||||||
or history compaction preserving an orphan)"
|
|
||||||
);
|
|
||||||
|
|
||||||
remove_indices.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove in descending index order to avoid shifting
|
|
||||||
for &idx in &remove_indices {
|
|
||||||
messages.remove(idx);
|
|
||||||
removed += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
|
|
||||||
// trailing ones). A tool message is orphaned if its tool_call_id has no
|
|
||||||
// matching parent assistant remaining in the history.
|
|
||||||
if !with_parent.is_empty() || !resolved_ids.is_empty() {
|
|
||||||
let mut i = 0;
|
|
||||||
while i < messages.len() {
|
|
||||||
let msg = &messages[i];
|
|
||||||
if msg.role == "tool" {
|
|
||||||
let is_orphaned = match &msg.tool_call_id {
|
|
||||||
Some(tc_id) => !with_parent.contains(tc_id),
|
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
if is_orphaned {
|
|
||||||
tracing::warn!(
|
|
||||||
tool_call_id = ?msg.tool_call_id,
|
|
||||||
message_id = %msg.id,
|
|
||||||
message_index = i,
|
|
||||||
"Removing orphaned tool result message — its parent assistant \
|
|
||||||
tool_calls message was removed or never persisted"
|
|
||||||
);
|
|
||||||
messages.remove(i);
|
|
||||||
removed += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// InboundMessage - Message from Channel to Bus (user input)
|
// InboundMessage - Message from Channel to Bus (user input)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -555,7 +555,7 @@ impl InitWizard {
|
|||||||
provider: selected_provider.clone(),
|
provider: selected_provider.clone(),
|
||||||
model: selected_model.clone(),
|
model: selected_model.clone(),
|
||||||
max_tool_iterations: 100,
|
max_tool_iterations: 100,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20000,
|
||||||
context_tool_result_trim_chars: 2000,
|
context_tool_result_trim_chars: 2000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -438,7 +438,7 @@ fn default_max_tool_iterations() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_tool_result_max_chars() -> usize {
|
fn default_tool_result_max_chars() -> usize {
|
||||||
100_000
|
20_000
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_context_tool_result_trim_chars() -> usize {
|
fn default_context_tool_result_trim_chars() -> usize {
|
||||||
@ -1077,7 +1077,7 @@ mod tests {
|
|||||||
assert_eq!(provider_config.temperature, Some(0.0));
|
assert_eq!(provider_config.temperature, Some(0.0));
|
||||||
assert_eq!(provider_config.max_tokens, None);
|
assert_eq!(provider_config.max_tokens, None);
|
||||||
assert_eq!(provider_config.llm_timeout_secs, 120);
|
assert_eq!(provider_config.llm_timeout_secs, 120);
|
||||||
assert_eq!(provider_config.tool_result_max_chars, 100_000);
|
assert_eq!(provider_config.tool_result_max_chars, 20_000);
|
||||||
assert_eq!(provider_config.context_tool_result_trim_chars, 2_000);
|
assert_eq!(provider_config.context_tool_result_trim_chars, 2_000);
|
||||||
assert_eq!(provider_config.context_summary_char_budget(), 32_000);
|
assert_eq!(provider_config.context_summary_char_budget(), 32_000);
|
||||||
}
|
}
|
||||||
@ -1322,7 +1322,7 @@ mod tests {
|
|||||||
|
|
||||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||||
assert_eq!(config.agents["default"].max_tool_iterations, 100);
|
assert_eq!(config.agents["default"].max_tool_iterations, 100);
|
||||||
assert_eq!(config.agents["default"].tool_result_max_chars, 100_000);
|
assert_eq!(config.agents["default"].tool_result_max_chars, 20_000);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.agents["default"].context_tool_result_trim_chars,
|
config.agents["default"].context_tool_result_trim_chars,
|
||||||
2_000
|
2_000
|
||||||
|
|||||||
@ -121,7 +121,7 @@ mod tests {
|
|||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
model_extra: HashMap::new(),
|
model_extra: HashMap::new(),
|
||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 20_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
|
|||||||
@ -65,7 +65,7 @@ mod tests {
|
|||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
model_extra: HashMap::new(),
|
model_extra: HashMap::new(),
|
||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 20_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
|
|||||||
@ -742,8 +742,8 @@ mod tests {
|
|||||||
context_window_tokens: None,
|
context_window_tokens: None,
|
||||||
model_extra: HashMap::new(),
|
model_extra: HashMap::new(),
|
||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
}
|
}
|
||||||
@ -1004,8 +1004,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1052,8 +1052,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1130,8 +1130,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1218,8 +1218,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1308,8 +1308,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 1,
|
llm_timeout_secs: 1,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1397,8 +1397,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1468,8 +1468,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1548,8 +1548,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 30,
|
llm_timeout_secs: 30,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
@ -1615,8 +1615,8 @@ mod tests {
|
|||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
llm_timeout_secs: 1,
|
llm_timeout_secs: 1,
|
||||||
memory_maintenance_timeout_secs: 600,
|
memory_maintenance_timeout_secs: 600,
|
||||||
tool_result_max_chars: 100_000,
|
tool_result_max_chars: 20_000,
|
||||||
context_tool_result_trim_chars: 100_000,
|
context_tool_result_trim_chars: 20_000,
|
||||||
max_images_in_context: 1,
|
max_images_in_context: 1,
|
||||||
max_image_age_rounds: 10,
|
max_image_age_rounds: 10,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -729,45 +729,29 @@ impl SessionStore {
|
|||||||
|
|
||||||
let delta_messages =
|
let delta_messages =
|
||||||
load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?;
|
load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?;
|
||||||
let now = current_timestamp();
|
|
||||||
|
|
||||||
// Collect all new messages first, then sanitize incomplete tool call
|
|
||||||
// sequences before writing to DB. This prevents orphaned tool_calls
|
|
||||||
// (without corresponding tool results) from being persisted permanently
|
|
||||||
// when compaction preserves an incomplete sequence from the snapshot or
|
|
||||||
// captures a partial sequence from delta messages.
|
|
||||||
let mut new_messages: Vec<ChatMessage> = Vec::new();
|
|
||||||
|
|
||||||
for message in preserved_system_messages {
|
|
||||||
new_messages.push(clone_message_for_compaction(message, message.timestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
new_messages.push(clone_message_for_compaction(summary_message, now));
|
|
||||||
|
|
||||||
for message in preserved_messages.iter().chain(delta_messages.iter()) {
|
|
||||||
new_messages.push(clone_message_for_compaction(message, message.timestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
let removed =
|
|
||||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut new_messages);
|
|
||||||
if removed > 0 {
|
|
||||||
tracing::warn!(
|
|
||||||
removed_count = removed,
|
|
||||||
session_id = %session_id,
|
|
||||||
"Compaction removed incomplete tool call sequences from new history"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write sanitized messages to DB
|
|
||||||
let mut next_seq = current_max_seq + 1;
|
let mut next_seq = current_max_seq + 1;
|
||||||
|
let now = current_timestamp();
|
||||||
let mut inserted_count = 0_i64;
|
let mut inserted_count = 0_i64;
|
||||||
let mut active_user_turn_count = 0_i64;
|
let mut active_user_turn_count = 0_i64;
|
||||||
|
|
||||||
for message in &new_messages {
|
for message in preserved_system_messages {
|
||||||
if message.role == "user" {
|
let copied = clone_message_for_compaction(message, message.timestamp);
|
||||||
|
insert_message_with_seq(&tx, session_id, next_seq, &copied)?;
|
||||||
|
next_seq += 1;
|
||||||
|
inserted_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary_copy = clone_message_for_compaction(summary_message, now);
|
||||||
|
insert_message_with_seq(&tx, session_id, next_seq, &summary_copy)?;
|
||||||
|
next_seq += 1;
|
||||||
|
inserted_count += 1;
|
||||||
|
|
||||||
|
for message in preserved_messages.iter().chain(delta_messages.iter()) {
|
||||||
|
let copied = clone_message_for_compaction(message, message.timestamp);
|
||||||
|
if copied.role == "user" {
|
||||||
active_user_turn_count += 1;
|
active_user_turn_count += 1;
|
||||||
}
|
}
|
||||||
insert_message_with_seq(&tx, session_id, next_seq, message)?;
|
insert_message_with_seq(&tx, session_id, next_seq, &copied)?;
|
||||||
next_seq += 1;
|
next_seq += 1;
|
||||||
inserted_count += 1;
|
inserted_count += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use crate::text::take_prefix_chars;
|
|||||||
use crate::tools::traits::{Tool, ToolResult};
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
use crate::tools::extract_u64;
|
use crate::tools::extract_u64;
|
||||||
|
|
||||||
const MAX_CHARS: usize = 100_000;
|
const MAX_CHARS: usize = 128_000;
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
const DEFAULT_LIMIT: usize = 2000;
|
||||||
|
|
||||||
pub struct FileReadTool {
|
pub struct FileReadTool {
|
||||||
|
|||||||
@ -379,6 +379,11 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!taskResult && !isTaskTool && !hasResult && (
|
||||||
|
<div className="px-3 pb-2 text-xs text-zinc-500">
|
||||||
|
等待工具执行...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isTaskTool && !taskResult && !message.subagentTaskId && (
|
{isTaskTool && !taskResult && !message.subagentTaskId && (
|
||||||
<div className="px-3 pb-2 text-xs text-zinc-500">
|
<div className="px-3 pb-2 text-xs text-zinc-500">
|
||||||
子智能体正在执行...
|
子智能体正在执行...
|
||||||
@ -450,6 +455,9 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!hasArgs && !hasResult && (
|
||||||
|
<div className="text-xs text-zinc-500">等待工具执行...</div>
|
||||||
|
)}
|
||||||
{isTaskTool && message.subagentTaskId && (
|
{isTaskTool && message.subagentTaskId && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { MessageBubble } from './MessageBubble'
|
import { MessageBubble } from './MessageBubble'
|
||||||
import type { ChatMessage } from '../../types/protocol'
|
import type { ChatMessage } from '../../types/protocol'
|
||||||
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
@ -11,121 +11,12 @@ interface MessageListProps {
|
|||||||
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
|
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const isStickyRef = useRef(true)
|
|
||||||
const prevShowBottomRef = useRef(false)
|
|
||||||
const prevShowTopRef = useRef(false)
|
|
||||||
const lastMessageCountRef = useRef(0)
|
|
||||||
const isProgrammaticScrollRef = useRef(false)
|
|
||||||
const scrollTimerRef = useRef<number>(0)
|
|
||||||
|
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
|
||||||
const [showScrollToTop, setShowScrollToTop] = useState(false)
|
|
||||||
const [hasNewMessage, setHasNewMessage] = useState(false)
|
|
||||||
|
|
||||||
// ---- scroll handlers ----
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const el = containerRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
|
||||||
const nearBottom = distanceFromBottom < 120
|
|
||||||
|
|
||||||
isStickyRef.current = nearBottom
|
|
||||||
|
|
||||||
// 编程式滚动进行中(按钮点击触发),跳过按钮状态更新,避免闪烁
|
|
||||||
if (isProgrammaticScrollRef.current) return
|
|
||||||
|
|
||||||
// 回到顶部按钮:滚过 0.8 个视口时显示
|
|
||||||
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
|
|
||||||
if (shouldShowTop !== prevShowTopRef.current) {
|
|
||||||
prevShowTopRef.current = shouldShowTop
|
|
||||||
setShowScrollToTop(shouldShowTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回到底部按钮:离开底部时显示
|
|
||||||
const shouldShowBottom = !nearBottom
|
|
||||||
if (shouldShowBottom !== prevShowBottomRef.current) {
|
|
||||||
prevShowBottomRef.current = shouldShowBottom
|
|
||||||
setShowScrollToBottom(shouldShowBottom)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户手动滚回底部时清除"有新消息"标记
|
|
||||||
if (nearBottom && hasNewMessage) {
|
|
||||||
setHasNewMessage(false)
|
|
||||||
}
|
|
||||||
}, [hasNewMessage])
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
||||||
isStickyRef.current = true
|
|
||||||
prevShowBottomRef.current = false
|
|
||||||
setShowScrollToBottom(false)
|
|
||||||
setHasNewMessage(false)
|
|
||||||
isProgrammaticScrollRef.current = true
|
|
||||||
clearTimeout(scrollTimerRef.current)
|
|
||||||
scrollTimerRef.current = setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false
|
|
||||||
}, 500)
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollToTop = useCallback(() => {
|
|
||||||
isProgrammaticScrollRef.current = true
|
|
||||||
clearTimeout(scrollTimerRef.current)
|
|
||||||
scrollTimerRef.current = setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false
|
|
||||||
}, 500)
|
|
||||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ---- auto-scroll effect ----
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return
|
if (bottomRef.current) {
|
||||||
|
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||||
const lastMessage = messages[messages.length - 1]
|
|
||||||
|
|
||||||
// 用户自己发的消息 → 始终滚到底部
|
|
||||||
if (lastMessage.role === 'user') {
|
|
||||||
scrollToBottom('instant')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}, [messages])
|
||||||
// 用户在底部 → 自动跟随新消息
|
|
||||||
if (isStickyRef.current && bottomRef.current) {
|
|
||||||
bottomRef.current.scrollIntoView({ behavior: 'instant' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户不在底部且有新消息 → 标记未读
|
|
||||||
if (!isStickyRef.current && messages.length > lastMessageCountRef.current) {
|
|
||||||
setHasNewMessage(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMessageCountRef.current = messages.length
|
|
||||||
}, [messages, scrollToBottom])
|
|
||||||
|
|
||||||
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
if (isStickyRef.current && bottomRef.current) {
|
|
||||||
bottomRef.current.scrollIntoView({ behavior: 'instant' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
observer.observe(el)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ---- 清理定时器 ----
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => clearTimeout(scrollTimerRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ---- empty state ----
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -145,69 +36,15 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- main render ----
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<div
|
||||||
<div
|
ref={containerRef}
|
||||||
ref={containerRef}
|
className="h-full overflow-y-auto p-6 space-y-6"
|
||||||
onScroll={handleScroll}
|
>
|
||||||
className="h-full overflow-y-auto p-6 space-y-6 scroll-smooth"
|
{messages.map((message) => (
|
||||||
>
|
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
|
||||||
{messages.map((message) => (
|
))}
|
||||||
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
|
<div ref={bottomRef} />
|
||||||
))}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 浮动导航按钮组 */}
|
|
||||||
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
|
|
||||||
{/* 回到顶部 */}
|
|
||||||
{showScrollToTop && (
|
|
||||||
<button
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="pointer-events-auto group relative flex items-center justify-center
|
|
||||||
w-9 h-9 rounded-full
|
|
||||||
bg-[#1a1a25]/80 backdrop-blur-md
|
|
||||||
border border-white/[0.06]
|
|
||||||
text-zinc-500 hover:text-[#00f0ff]
|
|
||||||
hover:border-[#00f0ff]/30
|
|
||||||
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
|
|
||||||
transition-all duration-300 ease-out
|
|
||||||
animate-fade-in"
|
|
||||||
aria-label="回到顶部"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 回到底部 */}
|
|
||||||
{showScrollToBottom && (
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToBottom('smooth')}
|
|
||||||
className="pointer-events-auto group relative flex items-center justify-center
|
|
||||||
w-9 h-9 rounded-full
|
|
||||||
bg-[#1a1a25]/80 backdrop-blur-md
|
|
||||||
border border-white/[0.06]
|
|
||||||
text-zinc-500 hover:text-[#00f0ff]
|
|
||||||
hover:border-[#00f0ff]/30
|
|
||||||
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
|
|
||||||
transition-all duration-300 ease-out
|
|
||||||
animate-fade-in"
|
|
||||||
aria-label="回到底部"
|
|
||||||
>
|
|
||||||
<ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} />
|
|
||||||
|
|
||||||
{/* 未读消息指示点 */}
|
|
||||||
{hasNewMessage && (
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#00f0ff]/60"></span>
|
|
||||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[#00f0ff]"></span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user