feat(ui): add session content search via API (#7050)
This commit is contained in:
@@ -377,6 +377,7 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
super::routes::action_required::confirm_tool_action,
|
super::routes::action_required::confirm_tool_action,
|
||||||
super::routes::reply::reply,
|
super::routes::reply::reply,
|
||||||
super::routes::session::list_sessions,
|
super::routes::session::list_sessions,
|
||||||
|
super::routes::session::search_sessions,
|
||||||
super::routes::session::get_session,
|
super::routes::session::get_session,
|
||||||
super::routes::session::get_session_insights,
|
super::routes::session::get_session_insights,
|
||||||
super::routes::session::update_session_name,
|
super::routes::session::update_session_name,
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ async fn get_session_extensions(
|
|||||||
pub fn routes(state: Arc<AppState>) -> Router {
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/sessions", get(list_sessions))
|
.route("/sessions", get(list_sessions))
|
||||||
|
.route("/sessions/search", get(search_sessions))
|
||||||
.route("/sessions/{session_id}", get(get_session))
|
.route("/sessions/{session_id}", get(get_session))
|
||||||
.route("/sessions/{session_id}", delete(delete_session))
|
.route("/sessions/{session_id}", delete(delete_session))
|
||||||
.route("/sessions/{session_id}/export", get(export_session))
|
.route("/sessions/{session_id}/export", get(export_session))
|
||||||
@@ -510,3 +511,88 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
|||||||
)
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchSessionsQuery {
|
||||||
|
/// Search query string (keywords separated by spaces)
|
||||||
|
query: String,
|
||||||
|
/// Maximum number of results to return (default: 10, max: 50)
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
limit: usize,
|
||||||
|
/// Filter results to sessions after this date (ISO 8601 format)
|
||||||
|
after_date: Option<String>,
|
||||||
|
/// Filter results to sessions before this date (ISO 8601 format)
|
||||||
|
before_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> usize {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/sessions/search",
|
||||||
|
params(
|
||||||
|
("query" = String, Query, description = "Search query string"),
|
||||||
|
("limit" = Option<usize>, Query, description = "Maximum results (default: 10, max: 50)"),
|
||||||
|
("after_date" = Option<String>, Query, description = "Filter after date (ISO 8601)"),
|
||||||
|
("before_date" = Option<String>, Query, description = "Filter before date (ISO 8601)")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Matching sessions", body = Vec<Session>),
|
||||||
|
(status = 400, description = "Bad request - Invalid query"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = [])
|
||||||
|
),
|
||||||
|
tag = "Session Management"
|
||||||
|
)]
|
||||||
|
async fn search_sessions(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<SearchSessionsQuery>,
|
||||||
|
) -> Result<Json<Vec<Session>>, StatusCode> {
|
||||||
|
let query = params.query.trim();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = params.limit.min(50);
|
||||||
|
|
||||||
|
let after_date = params
|
||||||
|
.after_date
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||||
|
|
||||||
|
let before_date = params
|
||||||
|
.before_date
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||||
|
|
||||||
|
let search_results = state
|
||||||
|
.session_manager()
|
||||||
|
.search_chat_history(query, Some(limit), after_date, before_date, None)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// Get full Session objects for matching session IDs
|
||||||
|
let session_ids: Vec<String> = search_results
|
||||||
|
.results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.session_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let all_sessions = state
|
||||||
|
.session_manager()
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let matching_sessions: Vec<Session> = all_sessions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| session_ids.contains(&s.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(matching_sessions))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2745,6 +2745,85 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sessions/search": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Session Management"
|
||||||
|
],
|
||||||
|
"operationId": "search_sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "query",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Search query string",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Maximum results (default: 10, max: 50)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "after_date",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter after date (ISO 8601)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "before_date",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter before date (ISO 8601)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Matching sessions",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad request - Invalid query"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sessions/{session_id}": {
|
"/sessions/{session_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3530,6 +3530,54 @@ export type GetSessionInsightsResponses = {
|
|||||||
|
|
||||||
export type GetSessionInsightsResponse = GetSessionInsightsResponses[keyof GetSessionInsightsResponses];
|
export type GetSessionInsightsResponse = GetSessionInsightsResponses[keyof GetSessionInsightsResponses];
|
||||||
|
|
||||||
|
export type SearchSessionsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Search query string
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
/**
|
||||||
|
* Maximum results (default: 10, max: 50)
|
||||||
|
*/
|
||||||
|
limit?: number | null;
|
||||||
|
/**
|
||||||
|
* Filter after date (ISO 8601)
|
||||||
|
*/
|
||||||
|
after_date?: string | null;
|
||||||
|
/**
|
||||||
|
* Filter before date (ISO 8601)
|
||||||
|
*/
|
||||||
|
before_date?: string | null;
|
||||||
|
};
|
||||||
|
url: '/sessions/search';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSessionsErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request - Invalid query
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Internal server error
|
||||||
|
*/
|
||||||
|
500: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSessionsResponses = {
|
||||||
|
/**
|
||||||
|
* Matching sessions
|
||||||
|
*/
|
||||||
|
200: Array<Session>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSessionsResponse = SearchSessionsResponses[keyof SearchSessionsResponses];
|
||||||
|
|
||||||
export type DeleteSessionData = {
|
export type DeleteSessionData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
forkSession,
|
forkSession,
|
||||||
importSession,
|
importSession,
|
||||||
listSessions,
|
listSessions,
|
||||||
|
searchSessions,
|
||||||
Session,
|
Session,
|
||||||
updateSessionName,
|
updateSessionName,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
@@ -342,7 +343,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||||||
}
|
}
|
||||||
}, [selectedSessionId, sessions]);
|
}, [selectedSessionId, sessions]);
|
||||||
|
|
||||||
// Debounced search effect - performs actual filtering
|
// Debounced search effect - performs content search via API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!debouncedSearchTerm) {
|
if (!debouncedSearchTerm) {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -352,32 +353,25 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use startTransition to make search non-blocking
|
// Call the backend search API for content search
|
||||||
startTransition(() => {
|
const performSearch = async () => {
|
||||||
const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase();
|
const resp = await searchSessions({
|
||||||
const filtered = sessions.filter((session) => {
|
query: { query: debouncedSearchTerm },
|
||||||
const description = session.name;
|
|
||||||
const workingDir = session.working_dir;
|
|
||||||
const sessionId = session.id;
|
|
||||||
|
|
||||||
if (caseSensitive) {
|
|
||||||
return (
|
|
||||||
description.includes(searchTerm) ||
|
|
||||||
sessionId.includes(searchTerm) ||
|
|
||||||
workingDir.includes(searchTerm)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
description.toLowerCase().includes(searchTerm) ||
|
|
||||||
sessionId.toLowerCase().includes(searchTerm) ||
|
|
||||||
workingDir.toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.data) {
|
||||||
|
// Response is Vec<Session> - sessions that match the search
|
||||||
|
const matchedSessionIds = new Set(resp.data.map((s: { id: string }) => s.id));
|
||||||
|
const filtered = sessions.filter((session) => matchedSessionIds.has(session.id));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setFilteredSessions(filtered);
|
||||||
|
setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setFilteredSessions(filtered);
|
performSearch();
|
||||||
setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null);
|
|
||||||
});
|
|
||||||
}, [debouncedSearchTerm, caseSensitive, sessions]);
|
}, [debouncedSearchTerm, caseSensitive, sessions]);
|
||||||
|
|
||||||
// Handle immediate search input (updates search term for debouncing)
|
// Handle immediate search input (updates search term for debouncing)
|
||||||
|
|||||||
Reference in New Issue
Block a user