Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,123 @@ pub async fn get_folder_conversation_outline(
get_folder_conversation_outline_core(&db.conn, conversation_id, before_index).await
}


fn char_boundary_snippet(text: &str, byte_pos: usize, query_len: usize) -> String {
let mut start = byte_pos.saturating_sub(30);
while start > 0 && !text.is_char_boundary(start) {
start -= 1;
}
let mut end = (byte_pos + query_len + 40).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end += 1;
}
let mut s = String::new();
if start > 0 {
s.push_str("... ");
}
if let Some(slice) = text.get(start..end) {
s.push_str(slice);
}
if end < text.len() {
s.push_str(" ...");
}
s.replace('\n', " ").trim().to_string()
}

/// In-conversation search for message content across all turns.
pub async fn search_conversation_messages_core(
conn: &sea_orm::DatabaseConnection,
conversation_id: i32,
query: &str,
) -> Result<Vec<ConversationMessageMatch>, AppCommandError> {
let query_trimmed = query.trim();
if query_trimmed.is_empty() {
return Ok(Vec::new());
}

let (detail, _parsed_title) = get_folder_conversation_core(conn, conversation_id).await?;
let query_lower = query_trimmed.to_lowercase();
let mut matches = Vec::new();

for (turn_index, turn) in detail.turns.into_iter().enumerate() {
let mut turn_text = String::new();
for block in &turn.blocks {
match block {
ContentBlock::Text { text } => {
turn_text.push_str(text);
turn_text.push('\n');
}
ContentBlock::ToolUse {
tool_name,
input_preview,
..
} => {
turn_text.push_str(tool_name);
turn_text.push(' ');
if let Some(input) = input_preview {
turn_text.push_str(input);
turn_text.push('\n');
}
}
ContentBlock::ToolResult {
output_preview: Some(out),
..
} => {
turn_text.push_str(out);
turn_text.push('\n');
}
ContentBlock::Thinking { text } => {
turn_text.push_str(text);
turn_text.push('\n');
}
ContentBlock::TurnError { message } => {
turn_text.push_str(message);
turn_text.push('\n');
}
ContentBlock::ImageGeneration {
revised_prompt: Some(prompt),
..
} => {
turn_text.push_str(prompt);
turn_text.push('\n');
}
_ => {}
}
}

let text_lower = turn_text.to_lowercase();
if text_lower.contains(&query_lower) {
let count = text_lower.match_indices(&query_lower).count();
let snippet = if let Some(pos) = text_lower.find(&query_lower) {
char_boundary_snippet(&turn_text, pos, query_lower.len())
} else {
String::new()
};

matches.push(ConversationMessageMatch {
turn_index,
turn_id: turn.id,
role: turn.role,
match_count: count,
snippet,
});
}
}

Ok(matches)
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn search_conversation_messages(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
query: String,
) -> Result<Vec<ConversationMessageMatch>, AppCommandError> {
search_conversation_messages_core(&db.conn, conversation_id, &query).await
}


/// Emit a `conversation://changed` Upsert for `conversation_id` so every
/// client's sidebar inserts-or-replaces the row in real time. Re-fetches the
/// fresh summary via `get_by_id`, which filters out soft-deleted rows — so an
Expand Down Expand Up @@ -7458,6 +7575,14 @@ mod tests {
);
}

#[test]
fn char_boundary_snippet_handles_multibyte_and_boundaries() {
let text = "你好世界,这是一个测试文本,用于测试对话内查找功能是否正常工作。";
let snippet = char_boundary_snippet(text, 15, 6);
assert!(!snippet.is_empty());
assert!(snippet.contains("测试"));
}

#[test]
fn apply_turn_window_from_index_is_exact_and_full_coverage_is_marked() {
let mut detail = windowless_detail(four_turns());
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1848,6 +1848,7 @@ mod tauri_app {
conversations::get_folder_conversation,
conversations::get_folder_conversation_turns,
conversations::get_folder_conversation_outline,
conversations::search_conversation_messages,
conversations::list_folders,
conversations::get_stats,
conversations::get_sidebar_data,
Expand Down
13 changes: 12 additions & 1 deletion src-tauri/src/models/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::agent::AgentType;
use super::message::{MessageTurn, TurnUsage};
use super::message::{MessageTurn, TurnRole, TurnUsage};
use crate::db::entities::conversation::ConversationKind;

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -233,6 +233,17 @@ pub struct ConversationOutline {
pub prefix_hash: String,
}

/// A search match for in-conversation search.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMessageMatch {
pub turn_index: usize,
pub turn_id: String,
pub role: TurnRole,
pub match_count: usize,
pub snippet: String,
}


#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderInfo {
pub path: String,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub use canvas::{CanvasBoard, CanvasBoardSummary, CanvasMutation, CanvasNode, Ca
pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
pub use conversation::{
AgentConversationCount, AgentStats, ConversationBranchTag, ConversationDetail,
ConversationOutline, ConversationSummary, ConversationTagDetail, ConversationTurnsPage,
ConversationMessageMatch, ConversationOutline, ConversationSummary, ConversationTagDetail, ConversationTurnsPage,
DbConversationDetail, DbConversationSummary, FolderInfo, ImportFolderOutcome, ImportResult,
ImportSelectedResult, ScanFolder, ScanResult, ScanSession, ScanSessionStatus,
SelectedSessionKey, SessionStats, SidebarData,
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/web/handlers/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,28 @@ pub async fn get_folder_conversation_outline(
Ok(Json(result))
}


#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchConversationMessagesParams {
pub conversation_id: i32,
pub query: String,
}

pub async fn search_conversation_messages(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SearchConversationMessagesParams>,
) -> Result<Json<Vec<crate::models::ConversationMessageMatch>>, AppCommandError> {
let result = conv_commands::search_conversation_messages_core(
&state.db.conn,
params.conversation_id,
&params.query,
)
.await?;
Ok(Json(result))
}


pub async fn list_folders(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Vec<FolderInfo>>, AppCommandError> {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/web/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ pub fn build_router_with_proxy_auth(
"/get_folder_conversation_outline",
post(handlers::conversations::get_folder_conversation_outline),
)
.route(
"/search_conversation_messages",
post(handlers::conversations::search_conversation_messages),
)
.route(
"/list_opened_tabs",
post(handlers::conversations::list_opened_tabs),
Expand Down
10 changes: 10 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3982,3 +3982,13 @@ div.monaco-diff-editor.side-by-side .editor.modified {
opacity: 0;
}
}

/* In-conversation find highlights (CSS Custom Highlight API - web standard find appearance) */
::highlight(search-match) {
background-color: #fde047;
color: #000000;
}
::highlight(search-match-active) {
background-color: #f97316;
color: #ffffff;
}
Loading
Loading