diff --git a/src/apps/desktop/src/api/insights_api.rs b/src/apps/desktop/src/api/insights_api.rs index 76a07178e..dc7411927 100644 --- a/src/apps/desktop/src/api/insights_api.rs +++ b/src/apps/desktop/src/api/insights_api.rs @@ -6,6 +6,7 @@ use serde::Deserialize; #[serde(rename_all = "camelCase")] pub struct GenerateInsightsRequest { pub days: Option, + pub model_id: Option, } #[derive(Debug, Clone, Deserialize)] @@ -17,12 +18,18 @@ pub struct LoadInsightsReportRequest { #[tauri::command] pub async fn generate_insights(request: GenerateInsightsRequest) -> Result { let days = request.days.unwrap_or(30); - info!("Generating insights for the last {} days", days); + let model_id = request.model_id; + info!( + "Generating insights for the last {} days with model selector {:?}", + days, model_id + ); - InsightsService::generate(days).await.map_err(|e| { - error!("Failed to generate insights: {}", e); - format!("Failed to generate insights: {}", e) - }) + InsightsService::generate(days, model_id) + .await + .map_err(|e| { + error!("Failed to generate insights: {}", e); + format!("Failed to generate insights: {}", e) + }) } #[tauri::command] diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0ed31c79f..44a350912 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -190,7 +190,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("cancel_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), ( "cancel_insights_generation", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::LocalOnly, ), ( "cancel_mcp_remote_oauth", @@ -383,7 +383,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "generate_greeting_only", RemoteWorkspacePolicy::LegacyUnaudited, ), - ("generate_insights", RemoteWorkspacePolicy::LegacyUnaudited), + ("generate_insights", RemoteWorkspacePolicy::RemoteRouted), ( "generate_session_title", RemoteWorkspacePolicy::LegacyUnaudited, @@ -481,10 +481,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_health_status", RemoteWorkspacePolicy::WorkspaceAgnostic, ), - ( - "get_latest_insights", - RemoteWorkspacePolicy::LegacyUnaudited, - ), + ("get_latest_insights", RemoteWorkspacePolicy::LocalOnly), ("get_mcp_prompt", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_mcp_remote_oauth_session", @@ -648,7 +645,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "grant_miniapp_workspace", RemoteWorkspacePolicy::LegacyUnaudited, ), - ("has_insights_data", RemoteWorkspacePolicy::LegacyUnaudited), + ("has_insights_data", RemoteWorkspacePolicy::RemoteRouted), ( "hide_agent_companion_desktop_pet", RemoteWorkspacePolicy::LocalOnly, @@ -770,10 +767,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "load_git_repo_history", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "load_insights_report", - RemoteWorkspacePolicy::LegacyUnaudited, - ), + ("load_insights_report", RemoteWorkspacePolicy::LocalOnly), ( "load_mcp_json_config", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/crates/assembly/core/src/agentic/insights/cancellation.rs b/src/crates/assembly/core/src/agentic/insights/cancellation.rs index 6fd4cef1f..087097711 100644 --- a/src/crates/assembly/core/src/agentic/insights/cancellation.rs +++ b/src/crates/assembly/core/src/agentic/insights/cancellation.rs @@ -1,27 +1,39 @@ use log::{debug, info, warn}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -type Slot = Arc>>; +#[derive(Clone)] +pub struct Registration { + pub id: u64, + pub token: CancellationToken, +} + +type Slot = Arc>>; static SLOT: std::sync::OnceLock = std::sync::OnceLock::new(); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); fn get_slot() -> Slot { SLOT.get_or_init(|| Arc::new(Mutex::new(None))).clone() } /// Registers a new insights generation task, cancelling any previous one. -pub async fn register() -> CancellationToken { +pub async fn register() -> Registration { let token = CancellationToken::new(); + let registration = Registration { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + token: token.clone(), + }; let arc = get_slot(); let mut slot = arc.lock().await; if let Some(old) = slot.take() { - old.cancel(); + old.token.cancel(); debug!("Cancelled previous insights generation"); } - *slot = Some(token.clone()); - token + *slot = Some(registration.clone()); + registration } /// Cancels the current insights generation task. @@ -30,7 +42,7 @@ pub async fn cancel() -> Result<(), String> { let mut slot = arc.lock().await; match slot.take() { Some(token) => { - token.cancel(); + token.token.cancel(); info!("Insights generation cancelled by user"); Ok(()) } @@ -42,8 +54,32 @@ pub async fn cancel() -> Result<(), String> { } /// Unregisters the current task (call on completion). -pub async fn unregister() { +pub async fn unregister(registration_id: u64) { let arc = get_slot(); let mut slot = arc.lock().await; - *slot = None; + if slot + .as_ref() + .is_some_and(|current| current.id == registration_id) + { + *slot = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn old_task_cannot_unregister_a_new_generation() { + let old = register().await; + let current = register().await; + assert!(old.token.is_cancelled()); + + unregister(old.id).await; + cancel() + .await + .expect("current generation remains registered"); + + assert!(current.token.is_cancelled()); + } } diff --git a/src/crates/assembly/core/src/agentic/insights/collector.rs b/src/crates/assembly/core/src/agentic/insights/collector.rs index 699400aca..dbbfae95c 100644 --- a/src/crates/assembly/core/src/agentic/insights/collector.rs +++ b/src/crates/assembly/core/src/agentic/insights/collector.rs @@ -1,15 +1,22 @@ use crate::agentic::core::{Message, MessageContent, MessageRole, ToolCall, ToolResult}; -use crate::agentic::insights::session_paths::collect_effective_session_storage_roots; +use crate::agentic::insights::session_paths::collect_effective_session_storage_targets; use crate::agentic::insights::types::*; use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::get_path_manager_arc; -use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; +use crate::service::session::{ + collect_hidden_subagent_cascade, estimate_turn_message_count, DialogTurnData, SessionMetadata, + ToolItemIdentityExt, TurnStatus, +}; +use crate::service::session_usage::{ + build_session_usage_report_from_turns, SessionUsageReportRequest, +}; +use crate::service::snapshot::get_snapshot_manager_for_workspace; use crate::util::errors::BitFunResult; use bitfun_agent_tools::ResolvedToolInvocation; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Local, Utc}; use log::{debug, warn}; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const MAX_TRANSCRIPT_CHARS: usize = 16000; @@ -39,15 +46,20 @@ impl InsightsCollector { pub async fn collect(days: u32) -> BitFunResult<(BaseStats, Vec)> { let path_manager = get_path_manager_arc(); let pm = PersistenceManager::new(path_manager)?; - let cutoff = SystemTime::now() - Duration::from_secs(days as u64 * 86400); + let now = SystemTime::now(); + let now_ms = system_time_to_unix_ms(now); + let cutoff_ms = now_ms.saturating_sub(days as u64 * 86_400_000); - let workspace_paths = collect_effective_session_storage_roots().await; + let workspace_targets = collect_effective_session_storage_targets().await; let mut transcripts = Vec::new(); let mut base_stats = BaseStats::default(); - let mut seen_session_ids = HashSet::new(); + let mut seen_sessions: HashSet<(PathBuf, String)> = HashSet::new(); + let mut modified_files: HashSet<(PathBuf, String)> = HashSet::new(); - for ws_path in &workspace_paths { + for target in &workspace_targets { + let ws_path = &target.session_storage_path; + let workspace_path = &target.workspace_path; let sessions = match pm.list_sessions(ws_path).await { Ok(s) => s, Err(e) => { @@ -55,18 +67,29 @@ impl InsightsCollector { continue; } }; + let metadata_including_internal = pm + .list_session_metadata_including_internal(ws_path) + .await + .unwrap_or_default(); + let recent_hidden_parent_ids = + recent_hidden_parent_session_ids(&metadata_including_internal, cutoff_ms); for summary in &sessions { - if summary.last_activity_at < cutoff { + if system_time_to_unix_ms(summary.last_activity_at) < cutoff_ms + && !recent_hidden_parent_ids.contains(&summary.session_id) + { continue; } - - if !seen_session_ids.insert(summary.session_id.clone()) { + let session_key = (ws_path.clone(), summary.session_id.clone()); + if !seen_sessions.insert(session_key) { continue; } - let session = match pm.load_session(ws_path, &summary.session_id).await { - Ok(s) => s, + let (session, parent_turns) = match pm + .load_session_with_turns(ws_path, &summary.session_id) + .await + { + Ok(value) => value, Err(e) => { warn!( "Skipping session {}: load failed: {}", @@ -76,54 +99,125 @@ impl InsightsCollector { } }; - let turns = pm - .load_session_turns(ws_path, &summary.session_id) - .await - .unwrap_or_default(); - - let messages = match Self::load_session_messages_with_turns( - &pm, - ws_path, + let parent_turn_ids = parent_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect::>(); + let hidden_session_ids = collect_hidden_subagent_cascade( + metadata_including_internal.iter().cloned(), &summary.session_id, - &turns, - ) - .await - { - Ok(m) if !m.is_empty() => m, - Ok(_) => { - debug!("Skipping session {}: no messages found", summary.session_id); - continue; - } - Err(e) => { - warn!( - "Skipping session {}: load messages failed: {}", - summary.session_id, e - ); - continue; + &parent_turn_ids, + ); + let hidden_session_id_set = hidden_session_ids.iter().collect::>(); + let linked_parent_turn_ids = metadata_including_internal + .iter() + .filter(|metadata| hidden_session_id_set.contains(&metadata.session_id)) + .filter_map(|metadata| { + metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_dialog_turn_id.clone()) + }) + .collect::>(); + + let mut selected_parent_turns = + filter_turns_for_window(&parent_turns, cutoff_ms, now_ms); + let mut transcript_turns = selected_parent_turns.clone(); + let transcript_turn_ids = transcript_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect::>(); + transcript_turns.extend( + parent_turns + .iter() + .filter(|turn| linked_parent_turn_ids.contains(&turn.turn_id)) + .filter(|turn| !transcript_turn_ids.contains(&turn.turn_id)) + .cloned(), + ); + let mut selected_turns = selected_parent_turns.clone(); + for hidden_session_id in hidden_session_ids { + match pm.load_session_turns(ws_path, &hidden_session_id).await { + Ok(turns) => { + selected_turns + .extend(filter_turns_for_window(&turns, cutoff_ms, now_ms)); + } + Err(error) => warn!( + "Skipping hidden subagent session {} for parent {}: {}", + hidden_session_id, summary.session_id, error + ), } - }; + } - let mut transcript = - Self::build_transcript(&summary.session_id, &session, &messages); - transcript.workspace_path = Some(ws_path.to_string_lossy().to_string()); - transcript.last_activity_unix_secs = summary - .last_activity_at - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Self::accumulate_stats(&mut base_stats, &session, &messages); - accumulate_code_stats_from_turns(&mut base_stats, &turns); + if selected_turns.is_empty() { + continue; + } + + selected_parent_turns.sort_by_key(|turn| turn.start_time); + transcript_turns.sort_by_key(|turn| turn.start_time); + selected_turns.sort_by_key(|turn| turn.start_time); + let messages = rebuild_messages_from_turns(&transcript_turns); + let message_count = selected_turns + .iter() + .map(estimate_turn_message_count) + .sum::(); + let duration_millis = compute_active_duration_millis( + &summary.session_id, + workspace_path, + &selected_turns, + now_ms, + ); + let duration_minutes = duration_millis / 60_000; + let first_activity_ms = selected_turns + .iter() + .map(|turn| turn.start_time.max(cutoff_ms)) + .min() + .unwrap_or(cutoff_ms); + let last_activity_ms = selected_turns + .iter() + .map(|turn| effective_turn_end_ms(turn, now_ms).min(now_ms)) + .max() + .unwrap_or(first_activity_ms); + + let mut transcript = Self::build_transcript( + &summary.session_id, + &session, + &messages, + selected_turns.len(), + message_count, + duration_minutes, + unix_ms_to_iso(first_activity_ms), + ); + transcript.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + transcript.last_activity_unix_secs = last_activity_ms / 1000; + + update_date_bounds(&mut base_stats, first_activity_ms, last_activity_ms); + Self::accumulate_stats( + &mut base_stats, + &session, + &selected_parent_turns, + &selected_turns, + message_count, + duration_millis, + ); + for path in + accumulate_code_stats(&mut base_stats, workspace_path, &selected_turns).await + { + modified_files.insert((workspace_path.clone(), path)); + } transcripts.push(transcript); } } base_stats.total_sessions = transcripts.len() as u32; - - if let Some(earliest) = transcripts.iter().min_by_key(|t| &t.created_at) { - base_stats.first_session_at = Some(earliest.created_at.clone()); - } - if let Some(latest) = transcripts.iter().max_by_key(|t| &t.created_at) { - base_stats.last_session_at = Some(latest.created_at.clone()); + base_stats.total_duration_minutes = base_stats.total_duration_millis / 60_000; + base_stats.total_files_modified = modified_files.len(); + for (_, path) in &modified_files { + if let Some(language) = language_name_for_path(path) { + *base_stats + .languages_by_files + .entry(language.to_string()) + .or_insert(0) += 1; + } } // Compute response time buckets from raw intervals @@ -144,35 +238,14 @@ impl InsightsCollector { Ok((base_stats, transcripts)) } - /// Load messages for a session, trying sources in priority order: - /// 1. Latest context snapshot (most complete, includes compression) - /// 2. Rebuild from pre-loaded turn data - async fn load_session_messages_with_turns( - pm: &PersistenceManager, - workspace_path: &Path, - session_id: &str, - turns: &[DialogTurnData], - ) -> BitFunResult> { - if let Ok(Some((_turn_index, messages))) = pm - .load_latest_turn_context_snapshot(workspace_path, session_id) - .await - { - if !messages.is_empty() { - return Ok(messages); - } - } - - if !turns.is_empty() { - return Ok(rebuild_messages_from_turns(turns)); - } - - Ok(vec![]) - } - fn build_transcript( session_id: &str, session: &crate::agentic::core::Session, messages: &[Message], + turn_count: usize, + message_count: usize, + duration_minutes: u64, + created_at: String, ) -> SessionTranscript { let mut all_parts: Vec = Vec::new(); let mut tool_names: Vec = Vec::new(); @@ -230,10 +303,6 @@ impl InsightsCollector { let transcript = smart_truncate_parts(&all_parts, MAX_TRANSCRIPT_CHARS, TAIL_RESERVE_CHARS); - let duration_minutes = Self::compute_active_duration(messages) / 60; - - let created_at = system_time_to_iso(session.created_at); - SessionTranscript { session_id: session_id.to_string(), agent_type: session.agent_type.clone(), @@ -241,8 +310,8 @@ impl InsightsCollector { workspace_path: None, last_activity_unix_secs: 0, duration_minutes, - message_count: messages.len() as u32, - turn_count: session.dialog_turn_ids.len() as u32, + message_count: message_count as u32, + turn_count: turn_count as u32, created_at, transcript, tool_names, @@ -253,87 +322,57 @@ impl InsightsCollector { fn accumulate_stats( base_stats: &mut BaseStats, session: &crate::agentic::core::Session, - messages: &[Message], + parent_turns: &[DialogTurnData], + all_turns: &[DialogTurnData], + message_count: usize, + duration_millis: u64, ) { - base_stats.total_messages += messages.len() as u32; - base_stats.total_turns += session.dialog_turn_ids.len() as u32; - - let active_secs = Self::compute_active_duration(messages); - base_stats.total_duration_minutes += active_secs / 60; + base_stats.total_messages += message_count as u32; + base_stats.total_turns += all_turns.len() as u32; + base_stats.total_duration_millis += duration_millis; + accumulate_session_token_usage(&mut base_stats.session_usage, all_turns); *base_stats .agent_types .entry(session.agent_type.clone()) .or_insert(0) += 1; - let mut last_assistant_time: Option = None; - for msg in messages { - if msg.role == MessageRole::User { - if let Ok(dur) = msg.timestamp.duration_since(UNIX_EPOCH) { - let dt = DateTime::::from(UNIX_EPOCH + dur); - let hour = dt.format("%H").to_string().parse::().unwrap_or(0); - *base_stats.hour_counts.entry(hour).or_insert(0) += 1; - } - } - - match &msg.content { - MessageContent::Mixed { tool_calls, .. } => { - for tc in tool_calls { - let tool_name = effective_tool_call_name(tc); - *base_stats.tool_usage.entry(tool_name).or_insert(0) += 1; - } - } - MessageContent::ToolResult { - tool_name, - effective_tool_name, - is_error: true, - .. - } => { + for turn in all_turns { + for round in &turn.model_rounds { + for tool in &round.tool_items { *base_stats - .tool_errors - .entry( - effective_tool_result_name(tool_name, effective_tool_name).to_string(), - ) + .tool_usage + .entry(tool.effective_name().to_string()) .or_insert(0) += 1; - } - _ => {} - } - - match msg.role { - MessageRole::Assistant => { - last_assistant_time = Some(msg.timestamp); - } - MessageRole::User => { - if let Some(prev) = last_assistant_time { - if let Ok(duration) = msg.timestamp.duration_since(prev) { - let secs = duration.as_secs(); - if (2..=ACTIVITY_GAP_THRESHOLD_SECS).contains(&secs) { - base_stats.response_times_raw.push(secs as f64); - } - } + if tool + .tool_result + .as_ref() + .is_some_and(|result| !result.success) + { + *base_stats + .tool_errors + .entry(tool.effective_name().to_string()) + .or_insert(0) += 1; } } - _ => {} } } - } - /// Compute active usage duration by summing adjacent message gaps, - /// capping each gap at `ACTIVITY_GAP_THRESHOLD_SECS`. - fn compute_active_duration(messages: &[Message]) -> u64 { - if messages.len() < 2 { - return 0; - } - let mut total_secs: u64 = 0; - for pair in messages.windows(2) { - if let Ok(gap) = pair[1].timestamp.duration_since(pair[0].timestamp) { - let gap_secs = gap.as_secs(); - if gap_secs <= ACTIVITY_GAP_THRESHOLD_SECS { - total_secs += gap_secs; + let mut previous_end_ms = None; + for turn in parent_turns { + let dt = DateTime::::from(UNIX_EPOCH + Duration::from_millis(turn.start_time)); + let hour = dt.format("%H").to_string().parse::().unwrap_or(0); + *base_stats.hour_counts.entry(hour).or_insert(0) += 1; + + if let Some(previous_end_ms) = previous_end_ms { + let response_ms = turn.start_time.saturating_sub(previous_end_ms); + let response_secs = response_ms / 1000; + if (2..=ACTIVITY_GAP_THRESHOLD_SECS).contains(&response_secs) { + base_stats.response_times_raw.push(response_secs as f64); } } + previous_end_ms = Some(effective_turn_end_ms(turn, turn.start_time)); } - total_secs } /// Stage 3: Aggregate facets into InsightsAggregate @@ -390,7 +429,7 @@ impl InsightsCollector { top_goals.sort_by_key(|entry| std::cmp::Reverse(entry.1)); top_goals.truncate(10); - let hours = base_stats.total_duration_minutes as f32 / 60.0; + let hours = base_stats.total_duration_millis as f32 / 3_600_000.0; let date_range = DateRange { start: base_stats.first_session_at.clone().unwrap_or_default(), end: base_stats.last_session_at.clone().unwrap_or_default(), @@ -432,10 +471,159 @@ impl InsightsCollector { total_lines_added: base_stats.total_lines_added, total_lines_removed: base_stats.total_lines_removed, total_files_modified: base_stats.total_files_modified, + session_usage: base_stats.session_usage.clone(), } } } +fn accumulate_session_token_usage(aggregate: &mut InsightsSessionUsage, turns: &[DialogTurnData]) { + aggregate.total_turns = aggregate.total_turns.saturating_add(turns.len() as u32); + for turn in turns { + let Some(usage) = turn.token_usage.as_ref() else { + continue; + }; + aggregate.input_tokens = aggregate.input_tokens.saturating_add(usage.input_tokens); + aggregate.total_tokens = aggregate.total_tokens.saturating_add(usage.total_tokens); + aggregate.turns_with_usage = aggregate.turns_with_usage.saturating_add(1); + if let Some(output_tokens) = usage.output_tokens { + aggregate.output_tokens = aggregate.output_tokens.saturating_add(output_tokens); + aggregate.output_reported_turns = aggregate.output_reported_turns.saturating_add(1); + } + } +} + +fn filter_turns_for_window( + turns: &[DialogTurnData], + cutoff_ms: u64, + now_ms: u64, +) -> Vec { + turns + .iter() + .filter(|turn| { + turn.kind.is_model_visible() + && turn.start_time <= now_ms + && effective_turn_end_ms(turn, now_ms) >= cutoff_ms + }) + .cloned() + .collect() +} + +fn recent_hidden_parent_session_ids( + metadata: &[SessionMetadata], + cutoff_ms: u64, +) -> HashSet { + let metadata_by_id = metadata + .iter() + .map(|entry| (entry.session_id.as_str(), entry)) + .collect::>(); + let mut recent_parent_ids = HashSet::new(); + + for entry in metadata + .iter() + .filter(|entry| entry.should_hide_from_user_lists() && entry.last_active_at >= cutoff_ms) + { + let mut current = entry; + let mut visited = HashSet::new(); + while let Some(parent_session_id) = current + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()) + { + if !visited.insert(parent_session_id) { + break; + } + recent_parent_ids.insert(parent_session_id.to_string()); + let Some(parent) = metadata_by_id.get(parent_session_id) else { + break; + }; + current = parent; + } + } + + recent_parent_ids +} + +fn effective_turn_end_ms(turn: &DialogTurnData, fallback_end_ms: u64) -> u64 { + let mut end_ms = turn + .end_time + .into_iter() + .chain( + turn.duration_ms + .map(|duration| turn.start_time.saturating_add(duration)), + ) + .max() + .unwrap_or_else(|| fallback_end_ms.max(turn.start_time)); + + for round in &turn.model_rounds { + end_ms = end_ms.max( + round + .end_time + .into_iter() + .chain( + round + .duration_ms + .map(|duration| round.start_time.saturating_add(duration)), + ) + .max() + .unwrap_or(round.start_time), + ); + for tool in &round.tool_items { + end_ms = end_ms.max( + tool.end_time + .into_iter() + .chain( + tool.duration_ms + .map(|duration| tool.start_time.saturating_add(duration)), + ) + .max() + .unwrap_or(tool.start_time), + ); + } + } + + end_ms +} + +fn compute_active_duration_millis( + session_id: &str, + workspace_path: &Path, + turns: &[DialogTurnData], + now_ms: u64, +) -> u64 { + let report = build_session_usage_report_from_turns( + SessionUsageReportRequest { + session_id: session_id.to_string(), + workspace_path: Some(workspace_path.to_string_lossy().to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: false, + }, + turns, + &[], + i64::try_from(now_ms).unwrap_or(i64::MAX), + ); + report.time.active_turn_ms.unwrap_or(0) +} + +fn update_date_bounds(base_stats: &mut BaseStats, first_activity_ms: u64, last_activity_ms: u64) { + let first = unix_ms_to_iso(first_activity_ms); + let last = unix_ms_to_iso(last_activity_ms); + if base_stats + .first_session_at + .as_ref() + .is_none_or(|existing| first.as_str() < existing.as_str()) + { + base_stats.first_session_at = Some(first); + } + if base_stats + .last_session_at + .as_ref() + .is_none_or(|existing| last.as_str() > existing.as_str()) + { + base_stats.last_session_at = Some(last); + } +} + /// Rebuild `Vec` from turn data, including tool call and tool result information /// needed by `build_transcript` and `accumulate_stats`. /// Preserves timestamps from turn data and marks cancelled turns with `[Cancelled]`. @@ -592,14 +780,14 @@ fn truncate_text(text: &str, max_len: usize) -> String { } } -fn system_time_to_iso(t: SystemTime) -> String { - match t.duration_since(UNIX_EPOCH) { - Ok(dur) => { - let dt = DateTime::::from(UNIX_EPOCH + dur); - dt.to_rfc3339() - } - Err(_) => "unknown".to_string(), - } +fn system_time_to_unix_ms(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn unix_ms_to_iso(timestamp_ms: u64) -> String { + DateTime::::from(UNIX_EPOCH + Duration::from_millis(timestamp_ms)).to_rfc3339() } fn bucket_response_times(raw: &[f64]) -> HashMap { @@ -653,9 +841,11 @@ fn compute_days_covered(range: &DateRange) -> u32 { match (parse(&range.start), parse(&range.end)) { (Some(start), Some(end)) => { - let diff = end.signed_duration_since(start); - let days = diff.num_days().unsigned_abs() as u32; - days.max(1) + end.date_naive() + .signed_duration_since(start.date_naive()) + .num_days() + .unsigned_abs() as u32 + + 1 } _ => 1, } @@ -672,7 +862,63 @@ fn compute_days_covered(range: &DateRange) -> u32 { /// /// Per session, each distinct file path touched by Edit/Write contributes once to `languages_by_files` /// according to [`language_name_for_path`]. -fn accumulate_code_stats_from_turns(base_stats: &mut BaseStats, turns: &[DialogTurnData]) { +async fn accumulate_code_stats( + base_stats: &mut BaseStats, + workspace_path: &Path, + turns: &[DialogTurnData], +) -> HashSet { + let Some(snapshot_manager) = get_snapshot_manager_for_workspace(workspace_path) else { + return accumulate_code_stats_from_turns(base_stats, turns); + }; + + let mut turn_indexes_by_session: HashMap> = HashMap::new(); + for turn in turns { + turn_indexes_by_session + .entry(turn.session_id.clone()) + .or_default() + .insert(turn.turn_index); + } + + let mut modified_files = HashSet::new(); + let mut fallback_session_ids = HashSet::new(); + for (session_id, turn_indexes) in &turn_indexes_by_session { + match snapshot_manager.get_session(session_id).await { + Ok(snapshot) => { + for operation in snapshot + .operations + .into_iter() + .filter(|operation| turn_indexes.contains(&operation.turn_index)) + { + base_stats.total_lines_added += operation.diff_summary.lines_added; + base_stats.total_lines_removed += operation.diff_summary.lines_removed; + modified_files.insert(operation.file_path.to_string_lossy().to_string()); + } + } + Err(_) => { + fallback_session_ids.insert(session_id.clone()); + } + } + } + + if !fallback_session_ids.is_empty() { + let fallback_turns = turns + .iter() + .filter(|turn| fallback_session_ids.contains(&turn.session_id)) + .cloned() + .collect::>(); + modified_files.extend(accumulate_code_stats_from_turns( + base_stats, + &fallback_turns, + )); + } + + modified_files +} + +fn accumulate_code_stats_from_turns( + base_stats: &mut BaseStats, + turns: &[DialogTurnData], +) -> HashSet { let mut modified_files: HashSet = HashSet::new(); for turn in turns { @@ -742,16 +988,7 @@ fn accumulate_code_stats_from_turns(base_stats: &mut BaseStats, turns: &[DialogT } } - for path in &modified_files { - if let Some(lang) = language_name_for_path(path) { - *base_stats - .languages_by_files - .entry(lang.to_string()) - .or_insert(0) += 1; - } - } - - base_stats.total_files_modified += modified_files.len(); + modified_files } /// Infer a language label from a file path (extension or well-known filename). @@ -797,3 +1034,188 @@ fn language_name_for_path(path: &str) -> Option<&'static str> { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::session::{ + DialogTurnKind, DialogTurnTokenUsageData, ModelRoundData, SessionKind, SessionRelationship, + SessionRelationshipKind, TextItemData, UserMessageData, + }; + + fn test_turn( + turn_id: &str, + turn_index: usize, + kind: DialogTurnKind, + start_time: u64, + end_time: u64, + ) -> DialogTurnData { + let mut turn = DialogTurnData::new_with_kind( + kind, + turn_id.to_string(), + turn_index, + "session-1".to_string(), + Some("Agentic".to_string()), + UserMessageData { + id: format!("user-{turn_id}"), + content: format!("message-{turn_id}"), + timestamp: start_time, + metadata: None, + }, + ); + turn.timestamp = start_time; + turn.start_time = start_time; + turn.end_time = Some(end_time); + turn.duration_ms = Some(end_time.saturating_sub(start_time)); + turn.status = TurnStatus::Completed; + turn + } + + #[test] + fn window_filter_uses_turn_activity_and_excludes_local_commands() { + let old = test_turn("old", 0, DialogTurnKind::UserDialog, 1_000, 2_000); + let recent = test_turn("recent", 1, DialogTurnKind::UserDialog, 9_000, 10_000); + let local = test_turn("local", 2, DialogTurnKind::LocalCommand, 9_500, 10_000); + + let selected = filter_turns_for_window(&[old, recent, local], 8_000, 11_000); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].turn_id, "recent"); + } + + #[test] + fn active_duration_uses_persisted_turn_span() { + let turn = test_turn("long", 0, DialogTurnKind::UserDialog, 1_000, 61_000); + + let duration = + compute_active_duration_millis("session-1", Path::new("/workspace"), &[turn], 70_000); + + assert_eq!(duration, 60_000); + } + + #[test] + fn message_count_matches_session_metadata_contract() { + let mut turn = test_turn("message-count", 0, DialogTurnKind::UserDialog, 1_000, 2_000); + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: turn.turn_id.clone(), + round_index: 0, + round_group_id: None, + timestamp: 1_100, + text_items: vec![TextItemData { + id: "text-1".to_string(), + content: "assistant response".to_string(), + is_streaming: false, + timestamp: 1_200, + is_markdown: true, + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, + }], + tool_items: vec![], + thinking_items: vec![], + start_time: 1_100, + end_time: Some(1_900), + duration_ms: Some(800), + provider_id: None, + model_config_id: None, + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + + assert_eq!(estimate_turn_message_count(&turn), 2); + assert_eq!(rebuild_messages_from_turns(&[turn]).len(), 2); + } + + #[test] + fn session_token_usage_sums_included_turns_and_tracks_coverage() { + let mut first = test_turn("usage-1", 0, DialogTurnKind::UserDialog, 1_000, 2_000); + first.token_usage = Some(DialogTurnTokenUsageData { + input_tokens: 1_200, + output_tokens: Some(300), + total_tokens: 1_500, + timestamp: 2_000, + }); + let mut second = test_turn("usage-2", 1, DialogTurnKind::UserDialog, 3_000, 4_000); + second.token_usage = Some(DialogTurnTokenUsageData { + input_tokens: 800, + output_tokens: None, + total_tokens: 900, + timestamp: 4_000, + }); + let missing = test_turn("usage-3", 2, DialogTurnKind::UserDialog, 5_000, 6_000); + let mut usage = InsightsSessionUsage::default(); + + accumulate_session_token_usage(&mut usage, &[first, second, missing]); + + assert_eq!( + usage, + InsightsSessionUsage { + input_tokens: 2_000, + output_tokens: 300, + total_tokens: 2_400, + turns_with_usage: 2, + output_reported_turns: 1, + total_turns: 3, + } + ); + } + + #[test] + fn date_bounds_track_activity_and_days_are_inclusive() { + let mut stats = BaseStats::default(); + update_date_bounds(&mut stats, 86_400_000, 259_200_000); + let range = DateRange { + start: stats.first_session_at.expect("first activity"), + end: stats.last_session_at.expect("last activity"), + }; + + assert_eq!(compute_days_covered(&range), 3); + } + + #[test] + fn workspace_scoped_session_identity_does_not_collapse_equal_ids() { + let mut seen = HashSet::new(); + assert!(seen.insert((PathBuf::from("workspace-a"), "same-id".to_string()))); + assert!(seen.insert((PathBuf::from("workspace-b"), "same-id".to_string()))); + } + + #[test] + fn recent_hidden_subagent_keeps_its_parent_session_in_scope() { + let mut parent = SessionMetadata::new( + "parent".to_string(), + "Parent".to_string(), + "Agentic".to_string(), + "model".to_string(), + ); + parent.last_active_at = 1_000; + + let mut child = SessionMetadata::new( + "child".to_string(), + "Child".to_string(), + "GeneralPurpose".to_string(), + "model".to_string(), + ); + child.session_kind = SessionKind::Subagent; + child.last_active_at = 10_000; + child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some(parent.session_id.clone()), + ..Default::default() + }); + + let recent_parents = recent_hidden_parent_session_ids(&[parent, child], 8_000); + + assert!(recent_parents.contains("parent")); + } +} diff --git a/src/crates/assembly/core/src/agentic/insights/facet_cache.rs b/src/crates/assembly/core/src/agentic/insights/facet_cache.rs index b632d80eb..dbd04b86d 100644 --- a/src/crates/assembly/core/src/agentic/insights/facet_cache.rs +++ b/src/crates/assembly/core/src/agentic/insights/facet_cache.rs @@ -18,6 +18,10 @@ struct CachedFacetFile { pub fn compute_fingerprint(transcript: &SessionTranscript) -> String { let mut hasher = Sha256::new(); + if let Some(workspace_path) = transcript.workspace_path.as_deref() { + hasher.update(workspace_path.as_bytes()); + } + hasher.update(b"|"); hasher.update(transcript.session_id.as_bytes()); hasher.update(b"|"); hasher.update(transcript.last_activity_unix_secs.to_string().as_bytes()); @@ -28,22 +32,28 @@ pub fn compute_fingerprint(transcript: &SessionTranscript) -> String { format!("{:x}", hasher.finalize()) } -fn cache_file_path(session_id: &str) -> BitFunResult { +fn cache_file_path(transcript: &SessionTranscript) -> BitFunResult { let pm = get_path_manager_arc(); - let safe = session_id + let safe = transcript + .session_id .chars() .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) .collect::(); + let mut workspace_hasher = Sha256::new(); + if let Some(workspace_path) = transcript.workspace_path.as_deref() { + workspace_hasher.update(workspace_path.as_bytes()); + } + let workspace_hash = format!("{:x}", workspace_hasher.finalize()); Ok(pm .user_data_dir() .join(CACHE_SUBDIR) - .join(format!("{safe}.json"))) + .join(format!("{}-{safe}.json", &workspace_hash[..16]))) } pub async fn try_load_cached_facet( transcript: &SessionTranscript, ) -> BitFunResult> { - let path = match cache_file_path(&transcript.session_id) { + let path = match cache_file_path(transcript) { Ok(p) => p, Err(_) => return Ok(None), }; @@ -66,7 +76,7 @@ pub async fn save_cached_facet( transcript: &SessionTranscript, facet: &SessionFacet, ) -> BitFunResult<()> { - let path = cache_file_path(&transcript.session_id)?; + let path = cache_file_path(transcript)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; } @@ -79,3 +89,33 @@ pub async fn save_cached_facet( debug!("Saved facet cache {}", path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn transcript(workspace_path: &str) -> SessionTranscript { + SessionTranscript { + session_id: "same-session".to_string(), + agent_type: "Agentic".to_string(), + session_name: "Session".to_string(), + workspace_path: Some(workspace_path.to_string()), + last_activity_unix_secs: 10, + duration_minutes: 1, + message_count: 2, + turn_count: 1, + created_at: "2026-07-16T00:00:00Z".to_string(), + transcript: "[User]: test".to_string(), + tool_names: vec![], + has_errors: false, + } + } + + #[test] + fn fingerprint_is_scoped_by_workspace() { + assert_ne!( + compute_fingerprint(&transcript("workspace-a")), + compute_fingerprint(&transcript("workspace-b")) + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/insights/service.rs b/src/crates/assembly/core/src/agentic/insights/service.rs index e8bed779e..109e132a1 100644 --- a/src/crates/assembly/core/src/agentic/insights/service.rs +++ b/src/crates/assembly/core/src/agentic/insights/service.rs @@ -5,7 +5,6 @@ use crate::agentic::insights::html::generate_html; use crate::agentic::insights::prompt_context::{ aggregate_stats_json_for_prompt, friction_block, summaries_block, user_instructions_block, }; -use crate::agentic::insights::session_paths::collect_effective_session_storage_roots; use crate::agentic::insights::types::*; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::infrastructure::ai::AIClient; @@ -15,11 +14,11 @@ use crate::service::config::get_global_config_service; use crate::service::config::AppConfig; use crate::service::i18n::LocaleId; use crate::util::errors::{BitFunError, BitFunResult}; -use crate::util::types::Message; +use crate::util::types::{GeminiResponse, GeminiUsage, Message, ToolDefinition}; use log::{debug, info, warn}; use serde_json::Value; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; @@ -36,9 +35,95 @@ const FUN_ENDING_PROMPT_TEMPLATE: &str = include_str!("prompts/fun_ending.md"); const MAX_CONCURRENT_FACET_EXTRACTIONS: usize = 5; +#[derive(Clone)] +struct TrackedAIClient { + client: Arc, + usage: GenerationUsageTracker, +} + +impl TrackedAIClient { + fn new(client: Arc, usage: GenerationUsageTracker) -> Self { + Self { client, usage } + } + + async fn send_message( + &self, + messages: Vec, + tools: Option>, + ) -> anyhow::Result { + self.usage.record_model_call(); + let response = self.client.send_message(messages, tools).await?; + self.usage.record_reported_usage(response.usage.as_ref()); + Ok(response) + } +} + +#[derive(Clone, Default)] +struct GenerationUsageTracker { + usage: Arc>, +} + +impl GenerationUsageTracker { + fn with_usage(&self, update: impl FnOnce(&mut InsightsGenerationUsage) -> R) -> R { + let mut usage = self + .usage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + update(&mut usage) + } + + fn record_model_call(&self) { + self.with_usage(|usage| { + usage.model_calls = usage.model_calls.saturating_add(1); + }); + } + + fn record_reported_usage(&self, reported: Option<&GeminiUsage>) { + let Some(reported) = reported else { + return; + }; + + self.with_usage(|usage| { + usage.input_tokens = usage + .input_tokens + .saturating_add(reported.prompt_token_count as u64); + usage.output_tokens = usage + .output_tokens + .saturating_add(reported.candidates_token_count as u64); + usage.total_tokens = usage + .total_tokens + .saturating_add(reported.total_token_count as u64); + usage.reasoning_tokens = usage + .reasoning_tokens + .saturating_add(reported.reasoning_token_count.unwrap_or_default() as u64); + usage.cached_input_tokens = usage + .cached_input_tokens + .saturating_add(reported.cached_content_token_count.unwrap_or_default() as u64); + usage.cache_creation_input_tokens = usage + .cache_creation_input_tokens + .saturating_add(reported.cache_creation_token_count.unwrap_or_default() as u64); + usage.reported_model_calls = usage.reported_model_calls.saturating_add(1); + }); + } + + fn snapshot(&self) -> InsightsGenerationUsage { + self.with_usage(|usage| usage.clone()) + } +} + pub struct InsightsService; impl InsightsService { + fn validate_days(days: u32) -> BitFunResult<()> { + if (1..=3650).contains(&days) { + Ok(()) + } else { + Err(BitFunError::validation( + "Insights range must be between 1 and 3650 days", + )) + } + } + async fn get_user_language() -> String { match get_global_config_service().await { Ok(config_service) => match config_service.get_config::(Some("app")).await { @@ -79,10 +164,15 @@ impl InsightsService { } /// Main entry: run the full insights pipeline - pub async fn generate(days: u32) -> BitFunResult { - let token = cancellation::register().await; - let result = Self::generate_inner(days, &token).await; - cancellation::unregister().await; + pub async fn generate( + days: u32, + model_selector: Option, + ) -> BitFunResult { + Self::validate_days(days)?; + let registration = cancellation::register().await; + let result = + Self::generate_inner(days, model_selector.as_deref(), ®istration.token).await; + cancellation::unregister(registration.id).await; result } @@ -91,7 +181,11 @@ impl InsightsService { cancellation::cancel().await } - async fn generate_inner(days: u32, token: &CancellationToken) -> BitFunResult { + async fn generate_inner( + days: u32, + model_selector: Option<&str>, + token: &CancellationToken, + ) -> BitFunResult { let user_lang = Self::get_user_language().await; let lang_instruction = Self::build_language_instruction(&user_lang); debug!("Insights generation using language: {}", user_lang); @@ -118,20 +212,45 @@ impl InsightsService { let ai_factory = get_global_ai_client_factory() .await .map_err(|e| BitFunError::service(format!("Failed to get AI client factory: {}", e)))?; - let ai_client_fast = ai_factory - .get_client_resolved("fast") - .await - .map_err(|e| BitFunError::service(format!("Failed to resolve fast model: {}", e)))?; - - // Primary model for analysis stages — falls back to fast if not configured - let ai_client_primary = match ai_factory.get_client_resolved("primary").await { - Ok(client) => client, - Err(_) => { - warn!("Primary model not configured, falling back to fast model for analysis"); - ai_client_fast.clone() - } + let explicit_model = model_selector + .map(str::trim) + .filter(|selector| !selector.is_empty() && *selector != "auto"); + let (ai_client_fast, ai_client_primary) = if let Some(selector) = explicit_model { + let client = ai_factory + .get_client_resolved(selector) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to resolve selected insights model '{}': {}", + selector, e + )) + })?; + (client.clone(), client) + } else { + let fast = ai_factory.get_client_resolved("fast").await.map_err(|e| { + BitFunError::service(format!("Failed to resolve fast model: {}", e)) + })?; + + // Primary model for analysis stages — falls back to fast if not configured. + let primary = match ai_factory.get_client_resolved("primary").await { + Ok(client) => client, + Err(_) => { + warn!("Primary model not configured, falling back to fast model for analysis"); + fast.clone() + } + }; + (fast, primary) }; + let mut generation_models = vec![ai_client_fast.config.model.clone()]; + if ai_client_primary.config.model != ai_client_fast.config.model { + generation_models.push(ai_client_primary.config.model.clone()); + } + + let usage_tracker = GenerationUsageTracker::default(); + let ai_client_fast = TrackedAIClient::new(ai_client_fast, usage_tracker.clone()); + let ai_client_primary = TrackedAIClient::new(ai_client_primary, usage_tracker.clone()); + let facets = Self::extract_facets_adaptive(&ai_client_fast, &transcripts, &lang_instruction, token) .await?; @@ -183,6 +302,8 @@ impl InsightsService { at_a_glance, horizon, fun_ending, + usage_tracker.snapshot(), + generation_models, ); let report = Self::save_report(report, &user_lang).await?; @@ -204,7 +325,7 @@ impl InsightsService { // ============ Stage 2: Facet Extraction ============ async fn extract_facets_adaptive( - ai_client: &Arc, + ai_client: &TrackedAIClient, transcripts: &[SessionTranscript], lang_instruction: &str, token: &CancellationToken, @@ -337,7 +458,7 @@ impl InsightsService { } async fn extract_single_facet( - ai_client: &Arc, + ai_client: &TrackedAIClient, transcript: &SessionTranscript, lang_instruction: &str, ) -> BitFunResult { @@ -419,7 +540,7 @@ impl InsightsService { // ============ Stage 4a: Parallel Analysis ============ async fn generate_analysis_parallel( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate: &InsightsAggregate, lang_instruction: &str, ) -> ( @@ -685,7 +806,7 @@ impl InsightsService { // ============ Stage 4b: Synthesis ============ async fn generate_synthesis( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate: &InsightsAggregate, suggestions: &InsightsSuggestions, areas: &[ProjectArea], @@ -729,7 +850,7 @@ impl InsightsService { // ============ Individual Analysis Methods ============ async fn generate_suggestions( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate: &InsightsAggregate, lang_instruction: &str, ) -> BitFunResult { @@ -861,7 +982,7 @@ impl InsightsService { } async fn identify_areas( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate: &InsightsAggregate, lang_instruction: &str, ) -> BitFunResult> { @@ -911,7 +1032,7 @@ impl InsightsService { } async fn analyze_wins( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, summaries: &str, lang_instruction: &str, @@ -962,7 +1083,7 @@ impl InsightsService { } async fn analyze_friction( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, summaries: &str, friction_details: &str, @@ -1024,7 +1145,7 @@ impl InsightsService { } async fn analyze_interaction_style( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, summaries: &str, lang_instruction: &str, @@ -1071,7 +1192,7 @@ impl InsightsService { } async fn generate_at_a_glance( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, areas_text: &str, suggestions_text: &str, @@ -1126,7 +1247,7 @@ impl InsightsService { } async fn generate_horizon( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, summaries: &str, friction_details: &str, @@ -1183,7 +1304,7 @@ impl InsightsService { } async fn generate_fun_ending( - ai_client: &Arc, + ai_client: &TrackedAIClient, aggregate_json: &str, summaries: &str, lang_instruction: &str, @@ -1241,6 +1362,8 @@ impl InsightsService { at_a_glance: AtAGlance, horizon: HorizonResult, fun_ending: Option, + generation_usage: InsightsGenerationUsage, + generation_models: Vec, ) -> InsightsReport { let days_covered = if !aggregate.date_range.start.is_empty() && !aggregate.date_range.end.is_empty() { @@ -1254,7 +1377,11 @@ impl InsightsService { parse(&aggregate.date_range.end), ) { (Some(start), Some(end)) => { - end.signed_duration_since(start).num_days().unsigned_abs() as u32 + end.date_naive() + .signed_duration_since(start.date_naive()) + .num_days() + .unsigned_abs() as u32 + + 1 } _ => 1, } @@ -1273,6 +1400,9 @@ impl InsightsService { analyzed_sessions: aggregate.analyzed, total_messages: aggregate.messages, days_covered, + session_usage: aggregate.session_usage.clone(), + generation_usage, + generation_models, stats: InsightsStats { total_hours: aggregate.hours, msgs_per_day: aggregate.msgs_per_day, @@ -1375,23 +1505,36 @@ impl InsightsService { } pub async fn has_data(days: u32) -> BitFunResult { - let path_manager = get_path_manager_arc(); - let pm = PersistenceManager::new(path_manager)?; - let cutoff = SystemTime::now() - std::time::Duration::from_secs(days as u64 * 86400); + Self::validate_days(days)?; + let (_, transcripts) = InsightsCollector::collect(days).await?; + Ok(!transcripts.is_empty()) + } - for ws_path in collect_effective_session_storage_roots().await { - if let Ok(sessions) = pm.list_sessions(&ws_path).await { - if sessions.iter().any(|s| s.last_activity_at >= cutoff) { - return Ok(true); - } - } + pub async fn load_report(path: &str) -> BitFunResult { + let path_manager = get_path_manager_arc(); + let usage_dir = path_manager.user_data_dir().join("usage-data"); + let requested_path = std::path::PathBuf::from(path); + let valid_name = requested_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("insights-") && name.ends_with(".json")); + if !valid_name { + return Err(BitFunError::validation("Invalid insights report path")); } - Ok(false) - } + let canonical_usage_dir = tokio::fs::canonicalize(&usage_dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to resolve insights directory: {}", e)))?; + let canonical_path = tokio::fs::canonicalize(&requested_path) + .await + .map_err(|e| BitFunError::io(format!("Failed to resolve insights report: {}", e)))?; + if !canonical_path.starts_with(&canonical_usage_dir) { + return Err(BitFunError::validation( + "Insights report path is outside the report directory", + )); + } - pub async fn load_report(path: &str) -> BitFunResult { - let json_str = tokio::fs::read_to_string(path) + let json_str = tokio::fs::read_to_string(&canonical_path) .await .map_err(|e| BitFunError::io(format!("Failed to read report file: {}", e)))?; let report: InsightsReport = serde_json::from_str(&json_str) @@ -1453,6 +1596,9 @@ impl InsightsService { total_hours: report.stats.total_hours, top_goals, languages, + session_usage: report.session_usage, + generation_usage: report.generation_usage, + generation_models: report.generation_models, }); } Err(e) => { @@ -1486,8 +1632,6 @@ impl InsightsService { } } -use crate::agentic::persistence::PersistenceManager; - // ============ Intermediate result types (internal to service) ============ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -1651,3 +1795,40 @@ fn json_value_to_string(value: &Value) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generation_usage_tracker_aggregates_reported_calls_and_preserves_coverage() { + let tracker = GenerationUsageTracker::default(); + let reported = GeminiUsage { + prompt_token_count: 120, + candidates_token_count: 30, + total_token_count: 150, + reasoning_token_count: Some(12), + cached_content_token_count: Some(80), + cache_creation_token_count: Some(10), + }; + + tracker.record_model_call(); + tracker.record_reported_usage(Some(&reported)); + tracker.record_model_call(); + tracker.record_reported_usage(None); + + assert_eq!( + tracker.snapshot(), + InsightsGenerationUsage { + input_tokens: 120, + output_tokens: 30, + total_tokens: 150, + reasoning_tokens: 12, + cached_input_tokens: 80, + cache_creation_input_tokens: 10, + model_calls: 2, + reported_model_calls: 1, + } + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/insights/session_paths.rs b/src/crates/assembly/core/src/agentic/insights/session_paths.rs index a1a1bca82..c691c7a4c 100644 --- a/src/crates/assembly/core/src/agentic/insights/session_paths.rs +++ b/src/crates/assembly/core/src/agentic/insights/session_paths.rs @@ -6,6 +6,12 @@ use bitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; use std::collections::HashSet; use std::path::PathBuf; +#[derive(Debug, Clone)] +pub struct EffectiveSessionStorageTarget { + pub workspace_path: PathBuf, + pub session_storage_path: PathBuf, +} + /// Resolve the final sessions directory for a tracked workspace. pub async fn effective_session_storage_dir_for_workspace(ws: &WorkspaceInfo) -> PathBuf { let path_str = ws.root_path.to_string_lossy().to_string(); @@ -40,6 +46,14 @@ pub async fn effective_session_storage_dir_for_workspace(ws: &WorkspaceInfo) -> /// /// Each returned path is the value to pass to [`PersistenceManager::list_sessions`]. pub async fn collect_effective_session_storage_roots() -> Vec { + collect_effective_session_storage_targets() + .await + .into_iter() + .map(|target| target.session_storage_path) + .collect() +} + +pub async fn collect_effective_session_storage_targets() -> Vec { let mut paths = Vec::new(); let mut seen = HashSet::new(); @@ -51,7 +65,10 @@ pub async fn collect_effective_session_storage_roots() -> Vec { let sessions_dir = effective_session_storage_dir_for_workspace(&ws).await; if sessions_dir.exists() && seen.insert(sessions_dir.clone()) { - paths.push(sessions_dir); + paths.push(EffectiveSessionStorageTarget { + workspace_path: ws.root_path, + session_storage_path: sessions_dir, + }); } } diff --git a/src/crates/assembly/core/src/agentic/insights/types.rs b/src/crates/assembly/core/src/agentic/insights/types.rs index 0c69950c9..af79dabd8 100644 --- a/src/crates/assembly/core/src/agentic/insights/types.rs +++ b/src/crates/assembly/core/src/agentic/insights/types.rs @@ -30,6 +30,8 @@ pub struct BaseStats { pub total_messages: u32, pub total_turns: u32, pub total_duration_minutes: u64, + #[serde(default)] + pub total_duration_millis: u64, pub first_session_at: Option, pub last_session_at: Option, pub tool_usage: HashMap, @@ -54,6 +56,8 @@ pub struct BaseStats { /// Language labels inferred from edited file paths (Edit/Write); drives aggregate `languages`. #[serde(default)] pub languages_by_files: HashMap, + #[serde(default)] + pub session_usage: InsightsSessionUsage, } // ============ Stage 2: Facet Extraction (AI) ============ @@ -124,6 +128,8 @@ pub struct InsightsAggregate { pub total_lines_removed: usize, #[serde(default)] pub total_files_modified: usize, + #[serde(default)] + pub session_usage: InsightsSessionUsage, } // ============ Stage 4: AI Analysis Results ============ @@ -255,6 +261,13 @@ pub struct InsightsReport { pub total_messages: u32, pub days_covered: u32, + #[serde(default)] + pub session_usage: InsightsSessionUsage, + #[serde(default)] + pub generation_usage: InsightsGenerationUsage, + #[serde(default)] + pub generation_models: Vec, + pub stats: InsightsStats, pub at_a_glance: AtAGlance, @@ -276,6 +289,33 @@ pub struct InsightsReport { pub html_report_path: Option, } +/// Token usage accumulated from the persisted turns included in this report. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct InsightsSessionUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub turns_with_usage: u32, + pub output_reported_turns: u32, + pub total_turns: u32, +} + +/// Token usage reported by model calls made while generating one insights report. +/// +/// Providers may omit usage metadata, so callers should compare +/// `reported_model_calls` with `model_calls` before treating the totals as complete. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct InsightsGenerationUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub reasoning_tokens: u64, + pub cached_input_tokens: u64, + pub cache_creation_input_tokens: u64, + pub model_calls: u32, + pub reported_model_calls: u32, +} + /// Metadata for listing saved reports #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InsightsReportMeta { @@ -294,6 +334,12 @@ pub struct InsightsReportMeta { pub top_goals: Vec, #[serde(default)] pub languages: Vec, + #[serde(default)] + pub session_usage: InsightsSessionUsage, + #[serde(default)] + pub generation_usage: InsightsGenerationUsage, + #[serde(default)] + pub generation_models: Vec, } // ============ API Request/Response ============ @@ -301,4 +347,5 @@ pub struct InsightsReportMeta { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateInsightsRequest { pub days: Option, + pub model_id: Option, } diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index d1b9df929..009df6c90 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -161,7 +161,7 @@ fn legacy_custom_metadata_string(value: Option<&Value>, key: &str) -> Option usize { +pub fn estimate_turn_message_count(turn: &DialogTurnData) -> usize { let assistant_text_count: usize = turn .model_rounds .iter() diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 3f09e6f77..a59fd413d 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -20,11 +20,11 @@ pub use memory_workspace::{ MemoryWorkspaceDiff, MemoryWorkspaceGitError, }; pub use metadata::{ - build_session_index_snapshot, build_session_metadata, merge_session_custom_metadata, - refresh_session_metadata_from_turns, remove_session_index_entry, set_deep_review_cache, - set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship, - try_refresh_session_metadata_for_saved_turn, upsert_session_index_entry, - SessionMetadataBuildFacts, + build_session_index_snapshot, build_session_metadata, estimate_turn_message_count, + merge_session_custom_metadata, refresh_session_metadata_from_turns, remove_session_index_entry, + set_deep_review_cache, set_deep_review_run_manifest, set_review_target_evidence, + set_session_relationship, try_refresh_session_metadata_for_saved_turn, + upsert_session_index_entry, SessionMetadataBuildFacts, }; pub use metadata_store::{SessionMetadataStore, SessionMetadataStoreError}; pub use migration::{ diff --git a/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss b/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss index aa30a7463..ecdff9cdf 100644 --- a/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss +++ b/src/web-ui/src/app/scenes/my-agent/InsightsScene.scss @@ -87,7 +87,7 @@ $ins-label: 12px; // ============================================================ .insights-scene:not(.insights-scene--report) { - max-width: 840px; + max-width: 1080px; margin: 0 auto; width: 100%; } @@ -95,7 +95,15 @@ $ins-label: 12px; // List header .insights-scene__header { - padding: $ins-lg $ins-lg $ins-sm; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: $ins-lg; + padding: $ins-lg $ins-lg $ins-md; +} + +.insights-scene__header-identity { + min-width: 0; } .insights-scene__header-title { @@ -103,19 +111,13 @@ $ins-label: 12px; font-weight: 600; color: var(--color-text-primary); margin: 0 0 2px; - letter-spacing: -0.02em; + letter-spacing: 0; } .insights-scene__header-subtitle { font-size: $ins-small; color: var(--color-text-muted); - margin: 0 0 $ins-md; -} - -.insights-scene__header-meta { - display: flex; - flex-direction: column; - gap: $ins-sm; + margin: 0; } .insights-scene__header-actions { @@ -123,6 +125,87 @@ $ins-label: 12px; align-items: center; gap: $size-gap-3; flex-wrap: wrap; + justify-content: flex-end; +} + +.insights-scene__model-control, +.insights-scene__day-filter-group { + display: flex; + align-items: center; + gap: $size-gap-2; +} + +.insights-scene__model-select { + width: 260px; + min-width: 0; + + .select__trigger { + min-height: 42px; + padding-top: 5px; + padding-bottom: 5px; + } + + .select__dropdown { + width: 340px; + right: auto; + } +} + +.insights-model-select { + &__value { + align-items: flex-start; + flex-direction: column; + gap: 1px; + min-width: 0; + white-space: normal; + } + + &__value-name { + display: -webkit-box; + width: 100%; + overflow: hidden; + color: var(--color-text-primary); + font-size: 11px; + font-weight: 500; + line-height: 1.25; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + &__value-meta { + display: block; + width: 100%; + overflow: hidden; + color: var(--color-text-muted); + font-size: 9px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__option { + min-width: 0; + width: 100%; + } + + &__option-name { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; + } + + &__option-meta { + margin-top: 2px; + color: var(--color-text-muted); + font-size: 10px; + line-height: 1.3; + overflow-wrap: anywhere; + white-space: normal; + } } // Day range — same row layout as Agents zone tools (`bitfun-agents-scene__agent-filters`) @@ -134,13 +217,10 @@ $ins-label: 12px; } .insights-scene__day-filter-group { - display: flex; - align-items: center; - gap: $size-gap-2; flex-wrap: wrap; } -.insights-scene__day-filter-label { +.insights-scene__control-label { font-size: 11px; font-weight: $font-weight-medium; color: var(--color-text-muted); @@ -196,36 +276,179 @@ $ins-label: 12px; } } -.insights-scene__progress-info { - display: flex; - align-items: center; - gap: $ins-sm; - margin: $ins-xs $ins-lg 0; - font-size: $ins-small; - color: var(--color-text-muted); -} +.insights-generation { + margin: 0 $ins-lg $ins-md; + padding: $ins-md; + border: 1px solid $ins-border; + border-radius: $ins-r; + background: $ins-surface; + box-shadow: var(--shadow-xs); -.insights-scene__progress-count { - font-weight: 600; - color: $ins-blue; + &__status { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: $ins-md; + } + + &__status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: $ins-r-sm; + background: $ins-blue-bg; + color: $ins-blue; + } + + &__status-copy { min-width: 0; } + + &__eyebrow { + margin-bottom: 1px; + font-size: 10px; + font-weight: 600; + color: $ins-blue; + letter-spacing: 0; + } + + &__title { + font-size: 15px; + font-weight: 600; + line-height: 1.35; + color: var(--color-text-primary); + } + + &__detail { + margin-top: 2px; + overflow: hidden; + color: var(--color-text-muted); + font-size: $ins-label; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__elapsed { + display: flex; + align-items: center; + gap: $ins-xs; + color: var(--color-text-muted); + font-size: 11px; + white-space: nowrap; + + strong { + min-width: 36px; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; + font-weight: 600; + } + } + + &__bar { + height: 4px; + margin-top: $ins-md; + overflow: hidden; + border-radius: 2px; + background: $ins-surface-soft; + + span { + display: block; + height: 100%; + border-radius: inherit; + background: $ins-blue; + transition: width 0.3s ease; + } + } + + &__steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: $ins-sm; + margin-top: $ins-md; + } + + &__step { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; + color: var(--color-text-muted); + + &--active { color: $ins-blue; } + &--complete { color: $ins-green; } + } + + &__step-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 22px; + border: 1px solid $ins-border; + border-radius: 50%; + background: $ins-surface-soft; + + .insights-generation__step--active & { + border-color: color-mix(in srgb, $ins-blue 40%, $ins-border); + background: $ins-blue-bg; + } + + .insights-generation__step--complete & { + border-color: color-mix(in srgb, $ins-green 40%, $ins-border); + background: $ins-green-bg; + } + } + + &__step-label { + overflow: hidden; + font-size: 10px; + font-weight: 500; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + } } // History list .insights-scene__history { - padding: 0 $ins-lg $ins-lg; + padding: $ins-xs $ins-lg $ins-lg; flex: 1; overflow-y: auto; min-height: 0; } +.insights-scene__history-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $ins-md; + margin-bottom: $ins-sm; +} + .insights-scene__history-label { font-size: $ins-label; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0; color: var(--color-text-muted); - margin-bottom: $ins-sm; +} + +.insights-scene__history-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + margin-left: 6px; + padding: 0 5px; + border-radius: 9px; + background: $ins-surface-soft; + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 600; } .insights-scene__history-hint { @@ -234,7 +457,7 @@ $ins-label: 12px; font-size: 10px; opacity: 0.55; letter-spacing: 0; - margin-left: $ins-sm; + text-align: right; } .insights-scene__loading { @@ -254,9 +477,9 @@ $ins-label: 12px; } .insights-scene__report-list { - display: flex; - flex-direction: column; - gap: 4px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $ins-sm; } // Meta cards (report list items) @@ -293,7 +516,7 @@ $ins-label: 12px; color: var(--color-text-primary); } - &:hover &__stat { + &:hover &__metric { color: var(--color-text-secondary); } @@ -301,7 +524,7 @@ $ins-label: 12px; display: flex; align-items: baseline; justify-content: space-between; - margin-bottom: $ins-xs; + margin-bottom: $ins-md; gap: $ins-sm; } @@ -318,27 +541,65 @@ $ins-label: 12px; flex-shrink: 0; } - &__stats-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: $ins-xs; + &__metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); + gap: $ins-sm; + margin-bottom: $ins-md; } - &__stat { - display: inline-flex; + &__metric { + display: flex; align-items: center; - gap: 3px; - font-size: $ins-label; + min-width: 0; + gap: 7px; color: var(--color-text-muted); transition: color 0.2s; + + > span { + display: flex; + min-width: 0; + flex-direction: column; + font-size: 10px; + line-height: 1.25; + } + + strong { + overflow: hidden; + color: var(--color-text-primary); + font-size: $ins-small; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + &--session-tokens { color: $ins-blue; } + &--partial { color: $ins-orange; } + } + + &__details { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px $ins-md; + margin-top: -$ins-xs; + + > span { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--color-text-muted); + font-size: 10px; + line-height: 1.3; + } + } &__tags { display: flex; flex-wrap: wrap; gap: $ins-xs; - margin-top: $ins-xs; + margin-top: $ins-sm; } &__tag { @@ -356,6 +617,78 @@ $ins-label: 12px; color: $ins-green; } } + + &__generation-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px $ins-md; + margin-top: $ins-sm; + padding-top: $ins-sm; + border-top: 1px solid $ins-border; + color: var(--color-text-muted); + font-size: 9px; + line-height: 1.3; + opacity: 0.72; + + > span { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; + gap: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &--partial { color: $ins-orange; } + + .insights-meta-card:hover & { opacity: 0.9; } + } +} + +@media (max-width: 760px) { + .insights-scene__header { + align-items: stretch; + flex-direction: column; + gap: $ins-md; + } + + .insights-scene__header-actions { justify-content: space-between; } + .insights-scene__report-list { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 520px) { + .insights-scene__header, + .insights-scene__history { + padding-left: $ins-md; + padding-right: $ins-md; + } + + .insights-generation { + margin-left: $ins-md; + margin-right: $ins-md; + + &__status { grid-template-columns: 32px minmax(0, 1fr); } + &__status-icon { width: 32px; height: 32px; } + &__elapsed { grid-column: 2; } + &__steps { gap: $ins-xs; } + &__step { justify-content: center; } + &__step-label { display: none; } + } + + .insights-scene__header-actions { align-items: stretch; } + .insights-scene__model-control { width: 100%; flex-wrap: wrap; } + .insights-scene__model-select { + width: 100%; + flex: 0 0 100%; + + .select__dropdown { width: 100%; right: 0; } + } + .insights-scene__control-label { width: 100%; } + .insights-meta-card__top { align-items: flex-start; flex-direction: column; } + .insights-meta-card__range { flex-shrink: 1; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx index 7f3da1f3e..18f6c1a7b 100644 --- a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx +++ b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx @@ -1,13 +1,17 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import React, { useEffect, useCallback, useState, useRef } from 'react'; +import React, { useEffect, useCallback, useMemo, useState, useRef } from 'react'; import { ExternalLink, Copy, Check, ArrowLeft, Loader2, AlertTriangle, BarChart3, MessageSquare, Calendar, Clock, X, Target, Zap, Trophy, - AlertCircle, Lightbulb, Rocket, + AlertCircle, Lightbulb, Rocket, Database, ScanSearch, Layers3, + FileCheck2, Gauge, Sparkles, Brain, } from 'lucide-react'; -import { openPath } from '@tauri-apps/plugin-opener'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import type { InsightsReport, InsightsReportMeta, InsightsStats } from '@/infrastructure/api/insightsApi'; +import { insightsApi, type InsightsReport, type InsightsReportMeta, type InsightsStats } from '@/infrastructure/api/insightsApi'; +import { Select, type SelectOption } from '@/component-library'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { getProviderDisplayName } from '@/infrastructure/config/services/modelConfigs'; +import type { AIModelConfig } from '@/infrastructure/config/types'; import { useInsightsStore } from './insightsStore'; import { createLogger } from '@/shared/utils/logger'; import { notificationService } from '@/shared/notification-system'; @@ -31,18 +35,203 @@ const SECTIONS = [ const DAY_OPTIONS = [7, 14, 30, 90] as const; +const GENERATION_STEPS = [ + { + id: 'collect', + titleKey: 'insights.generationStageCollect', + detailKey: 'insights.generationStageCollectDetail', + icon: Database, + stages: ['starting', 'data_collection'], + }, + { + id: 'sessions', + titleKey: 'insights.generationStageSessions', + detailKey: 'insights.generationStageSessionsDetail', + icon: ScanSearch, + stages: ['facet_extraction', 'facet_retry'], + }, + { + id: 'patterns', + titleKey: 'insights.generationStagePatterns', + detailKey: 'insights.generationStagePatternsDetail', + icon: Layers3, + stages: ['aggregation', 'analysis', 'analysis_retry'], + }, + { + id: 'summary', + titleKey: 'insights.generationStageSummary', + detailKey: 'insights.generationStageSummaryDetail', + icon: Sparkles, + stages: ['synthesis'], + }, + { + id: 'save', + titleKey: 'insights.generationStageSave', + detailKey: 'insights.generationStageSaveDetail', + icon: FileCheck2, + stages: ['assembly', 'complete'], + }, +] as const; + +interface GenerationProgress { + stage: string; + message: string; + current: number; + total: number; + isRetrying: boolean; +} + +interface InsightsModelOption extends SelectOption { + modelName: string; + meta: string; +} + +const GenerationPanel: React.FC<{ progress: GenerationProgress }> = ({ progress }) => { + const { t } = useI18n('common'); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + return () => window.clearInterval(timer); + }, []); + + const activeIndex = Math.max( + 0, + GENERATION_STEPS.findIndex((step) => step.stages.some((stage) => stage === progress.stage)), + ); + const activeStep = GENERATION_STEPS[activeIndex]; + const itemProgress = progress.total > 0 + ? Math.min(1, Math.max(0, progress.current / progress.total)) + : 0; + const overallProgress = progress.stage === 'complete' + ? 100 + : Math.min(96, ((activeIndex + Math.max(itemProgress, 0.18)) / GENERATION_STEPS.length) * 100); + const elapsed = `${String(Math.floor(elapsedSeconds / 60)).padStart(2, '0')}:${String(elapsedSeconds % 60).padStart(2, '0')}`; + const detail = progress.current > 0 && progress.total > 0 + ? t('insights.generationItemsProgress', { current: progress.current, total: progress.total }) + : progress.isRetrying + ? t('insights.generationRetrying') + : t(activeStep.detailKey); + + return ( +
+
+
+ +
+
+
{t('insights.generating')}
+
{t(activeStep.titleKey)}
+
{detail}
+
+
+ + {t('insights.generationElapsed')} + {elapsed} +
+
+ + + +
+ {GENERATION_STEPS.map((step, index) => { + const StepIcon = step.icon; + const state = index < activeIndex ? 'complete' : index === activeIndex ? 'active' : 'pending'; + return ( +
+ + {state === 'complete' ? : } + + {t(step.titleKey)} +
+ ); + })} +
+
+ ); +}; + const InsightsScene: React.FC = () => { const { t } = useI18n('common'); + const [availableModels, setAvailableModels] = useState([]); const { view, reportMetas, currentReport, generating, progress, - selectedDays, error, loadingMetas, - setSelectedDays, fetchReportMetas, loadReport, generateReport, cancelGeneration, backToList, clearError, + selectedDays, selectedModel, error, loadingMetas, + setSelectedDays, setSelectedModel, fetchReportMetas, loadReport, generateReport, cancelGeneration, backToList, clearError, } = useInsightsStore(); useEffect(() => { fetchReportMetas(); }, [fetchReportMetas]); + useEffect(() => { + let active = true; + void configManager.getConfig('ai.models').then((models) => { + if (!active) return; + const enabledChatModels = (models || []).filter((model) => ( + model.enabled && model.id && model.capabilities?.includes('text_chat') + )); + setAvailableModels(enabledChatModels); + const currentSelection = useInsightsStore.getState().selectedModel; + if ( + currentSelection !== 'auto' + && !enabledChatModels.some((model) => model.id === currentSelection) + ) { + setSelectedModel('auto'); + } + }).catch((error) => { + log.warn('Failed to load models for insights', error); + }); + return () => { + active = false; + }; + }, [setSelectedModel]); + + const modelOptions = useMemo(() => [ + { + value: 'auto', + label: t('insights.modelAuto'), + description: t('insights.modelAutoDescription'), + modelName: t('insights.modelAuto'), + meta: t('insights.modelAutoDescription'), + }, + ...availableModels.map((model) => ({ + value: model.id || '', + label: model.model_name, + description: `${model.name} · ${getProviderDisplayName(model)}`, + modelName: model.model_name, + meta: `${model.name} · ${getProviderDisplayName(model)}`, + })), + ], [availableModels, t]); + + const renderModelValue = useCallback((option?: SelectOption | SelectOption[]) => { + const selected = (Array.isArray(option) ? option[0] : option) as InsightsModelOption | undefined; + if (!selected) return null; + const fullLabel = selected.meta ? `${selected.modelName} · ${selected.meta}` : selected.modelName; + return ( + + {selected.modelName} + {selected.meta && {selected.meta}} + + ); + }, []); + + const renderModelOption = useCallback((option: SelectOption) => { + const model = option as InsightsModelOption; + const fullLabel = model.meta ? `${model.modelName} · ${model.meta}` : model.modelName; + return ( +
+
{model.modelName}
+ {model.meta &&
{model.meta}
} +
+ ); + }, []); + if (view === 'report' && currentReport) { return ; } @@ -52,48 +241,56 @@ const InsightsScene: React.FC = () => {

{t('insights.title')}

-
-

{t('insights.subtitle')}

-
-
-
- - {t('insights.rangeLabel')} - - {DAY_OPTIONS.map((d) => ( - - ))} -
-
- {generating ? ( - - ) : ( +

{t('insights.subtitle')}

+
+
+
+ {t('insights.modelLabel')} +