From a7cb435206a6a75d042437c8062dd617d7576dc3 Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 6 Apr 2026 14:22:08 -0400 Subject: [PATCH] Feed active panel context into agent chat --- MosaicIQ/src-tauri/src/agent/gateway.rs | 30 +- MosaicIQ/src-tauri/src/agent/mod.rs | 9 +- MosaicIQ/src-tauri/src/agent/panel_context.rs | 773 ++++++++++++++++++ MosaicIQ/src-tauri/src/agent/service.rs | 171 +++- MosaicIQ/src-tauri/src/agent/types.rs | 12 + MosaicIQ/src-tauri/src/commands/terminal.rs | 1 + MosaicIQ/src-tauri/src/error.rs | 4 + MosaicIQ/src-tauri/src/terminal/mod.rs | 4 +- MosaicIQ/src-tauri/src/terminal/types.rs | 2 +- MosaicIQ/src/App.tsx | 3 + MosaicIQ/src/lib/chatPanelContext.ts | 52 ++ MosaicIQ/src/types/terminal.ts | 7 + 12 files changed, 1053 insertions(+), 15 deletions(-) create mode 100644 MosaicIQ/src-tauri/src/agent/panel_context.rs create mode 100644 MosaicIQ/src/lib/chatPanelContext.ts diff --git a/MosaicIQ/src-tauri/src/agent/gateway.rs b/MosaicIQ/src-tauri/src/agent/gateway.rs index e3db12e..05a1fb7 100644 --- a/MosaicIQ/src-tauri/src/agent/gateway.rs +++ b/MosaicIQ/src-tauri/src/agent/gateway.rs @@ -23,6 +23,7 @@ pub trait ChatGateway: Clone + Send + Sync + 'static { &self, runtime: AgentRuntimeConfig, prompt: String, + context_messages: Vec, history: Vec, ) -> BoxFuture<'static, Result>; } @@ -36,6 +37,7 @@ impl ChatGateway for RigChatGateway { &self, runtime: AgentRuntimeConfig, prompt: String, + context_messages: Vec, history: Vec, ) -> BoxFuture<'static, Result> { Box::pin(async move { @@ -51,7 +53,7 @@ impl ChatGateway for RigChatGateway { let upstream = model .completion_request(Message::user(prompt)) - .messages(history) + .messages(compose_request_messages(context_messages, history)) .preamble(SYSTEM_PROMPT.to_string()) .temperature(0.2) .stream() @@ -71,3 +73,29 @@ impl ChatGateway for RigChatGateway { }) } } + +fn compose_request_messages( + context_messages: Vec, + history: Vec, +) -> Vec { + context_messages.into_iter().chain(history).collect() +} + +#[cfg(test)] +mod tests { + use rig::completion::Message; + + use super::compose_request_messages; + + #[test] + fn prepends_context_messages_before_history() { + let messages = compose_request_messages( + vec![Message::system("panel context")], + vec![Message::user("previous"), Message::assistant("reply")], + ); + + assert_eq!(messages[0], Message::system("panel context")); + assert_eq!(messages[1], Message::user("previous")); + assert_eq!(messages[2], Message::assistant("reply")); + } +} diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index bcfc656..64e6ba9 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -1,6 +1,7 @@ //! Agent domain logic and request/response types. mod gateway; +mod panel_context; mod routing; mod service; mod settings; @@ -11,8 +12,8 @@ pub use service::AgentService; pub(crate) use settings::AgentSettingsService; pub use types::{ default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, - AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart, - PreparedChatTurn, RemoteProviderSettings, SaveAgentRuntimeConfigRequest, TaskProfile, - UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, - DEFAULT_REMOTE_MODEL, + AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPanelContext, + ChatPromptRequest, ChatStreamStart, PreparedChatTurn, RemoteProviderSettings, + SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, + AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; diff --git a/MosaicIQ/src-tauri/src/agent/panel_context.rs b/MosaicIQ/src-tauri/src/agent/panel_context.rs new file mode 100644 index 0000000..aa57592 --- /dev/null +++ b/MosaicIQ/src-tauri/src/agent/panel_context.rs @@ -0,0 +1,773 @@ +use serde_json::{json, Value}; + +use rig::completion::Message; + +use crate::agent::ChatPanelContext; +use crate::error::AppError; +use crate::terminal::{ + CashFlowPanelData, CashFlowPeriod, Company, CompanyPricePoint, DividendEvent, + DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FinancialsPanelData, + Holding, NewsItem, PanelPayload, Portfolio, SourceStatus, StatementPeriod, StockAnalysis, +}; + +const MAX_TEXT_FIELD_LENGTH: usize = 600; +const MAX_NEWS_ITEMS: usize = 5; +const MAX_PERIODS: usize = 4; +const MAX_DIVIDEND_EVENTS: usize = 4; +const MAX_HOLDINGS: usize = 25; +const PANEL_CONTEXT_INSTRUCTION: &str = "Hidden workspace panel context. This is current UI state, not a user-authored message. Use it only when relevant to the current request. Do not mention hidden context unless it directly supports the answer."; + +pub(crate) fn build_panel_context_message( + panel_context: &ChatPanelContext, +) -> Result { + let payload = json!({ + "kind": "workspacePanelContext", + "sourceCommand": panel_context.source_command, + "capturedAt": panel_context.captured_at, + "panelType": panel_type(&panel_context.panel), + "panel": compact_panel_payload(&panel_context.panel), + }); + + let serialized = serde_json::to_string(&payload) + .map_err(|error| AppError::PanelContext(error.to_string()))?; + + Ok(Message::system(format!( + "{PANEL_CONTEXT_INSTRUCTION}\n{serialized}" + ))) +} + +pub(crate) fn compact_panel_payload(panel: &PanelPayload) -> Value { + match panel { + PanelPayload::Company { data } => compact_company_panel(data), + PanelPayload::Error { data } => compact_error_panel(data), + PanelPayload::Portfolio { data } => compact_portfolio_panel(data), + PanelPayload::News { data, ticker } => compact_news_panel(data, ticker.as_deref()), + PanelPayload::Analysis { data } => compact_analysis_panel(data), + PanelPayload::Financials { data } => compact_financials_panel(data), + PanelPayload::CashFlow { data } => compact_cash_flow_panel(data), + PanelPayload::Dividends { data } => compact_dividends_panel(data), + PanelPayload::Earnings { data } => compact_earnings_panel(data), + } +} + +fn panel_type(panel: &PanelPayload) -> &'static str { + match panel { + PanelPayload::Company { .. } => "company", + PanelPayload::Error { .. } => "error", + PanelPayload::Portfolio { .. } => "portfolio", + PanelPayload::News { .. } => "news", + PanelPayload::Analysis { .. } => "analysis", + PanelPayload::Financials { .. } => "financials", + PanelPayload::CashFlow { .. } => "cashFlow", + PanelPayload::Dividends { .. } => "dividends", + PanelPayload::Earnings { .. } => "earnings", + } +} + +fn compact_company_panel(data: &Company) -> Value { + let mut chart_range_summaries = data + .price_chart_ranges + .as_ref() + .map(|ranges| { + let mut entries = ranges.iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + + entries + .into_iter() + .map(|(range, points)| { + json!({ + "range": range, + "summary": summarize_chart_points(points), + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + if let Some(points) = data.price_chart.as_ref() { + chart_range_summaries.push(json!({ + "range": "default", + "summary": summarize_chart_points(points), + })); + } + + json!({ + "symbol": data.symbol, + "name": data.name, + "price": data.price, + "change": data.change, + "changePercent": data.change_percent, + "marketCap": data.market_cap, + "volume": data.volume, + "volumeLabel": data.volume_label, + "pe": data.pe, + "eps": data.eps, + "high52Week": data.high52_week, + "low52Week": data.low52_week, + "profile": data.profile.as_ref().map(|profile| json!({ + "description": profile.description.as_deref().map(truncate_text), + "wikiUrl": profile.wiki_url, + "ceo": profile.ceo.as_deref().map(truncate_text), + "headquarters": profile.headquarters.as_deref().map(truncate_text), + "employees": profile.employees, + "founded": profile.founded, + "sector": profile.sector.as_deref().map(truncate_text), + "website": profile.website, + })), + "chartRangeSummaries": chart_range_summaries, + }) +} + +fn compact_error_panel(data: &ErrorPanel) -> Value { + json!({ + "title": truncate_text(&data.title), + "message": truncate_text(&data.message), + "detail": data.detail.as_deref().map(truncate_text), + "provider": data.provider, + "query": data.query.as_deref().map(truncate_text), + "symbol": data.symbol, + }) +} + +fn compact_portfolio_panel(data: &Portfolio) -> Value { + json!({ + "summary": { + "totalValue": data.total_value, + "dayChange": data.day_change, + "dayChangePercent": data.day_change_percent, + "totalGain": data.total_gain, + "totalGainPercent": data.total_gain_percent, + "cashBalance": data.cash_balance, + "investedCostBasis": data.invested_cost_basis, + "realizedGain": data.realized_gain, + "unrealizedGain": data.unrealized_gain, + "holdingsCount": data.holdings_count, + "stalePricingSymbols": data.stale_pricing_symbols, + }, + "holdings": data + .holdings + .iter() + .take(MAX_HOLDINGS) + .map(compact_holding) + .collect::>(), + }) +} + +fn compact_news_panel(data: &[NewsItem], ticker: Option<&str>) -> Value { + json!({ + "ticker": ticker, + "items": data + .iter() + .take(MAX_NEWS_ITEMS) + .map(|item| json!({ + "source": truncate_text(&item.source), + "headline": truncate_text(&item.headline), + "timestamp": item.timestamp, + "snippet": truncate_text(&item.snippet), + "url": item.url, + "relatedTickers": item.related_tickers, + })) + .collect::>(), + }) +} + +fn compact_analysis_panel(data: &StockAnalysis) -> Value { + json!({ + "symbol": data.symbol, + "summary": truncate_text(&data.summary), + "sentiment": data.sentiment, + "keyPoints": truncate_text_items(&data.key_points), + "risks": truncate_text_items(&data.risks), + "opportunities": truncate_text_items(&data.opportunities), + "recommendation": data.recommendation, + "targetPrice": data.target_price, + }) +} + +fn compact_financials_panel(data: &FinancialsPanelData) -> Value { + json!({ + "symbol": data.symbol, + "companyName": data.company_name, + "cik": data.cik, + "frequency": data.frequency, + "latestFiling": data.latest_filing, + "sourceStatus": compact_source_status(&data.source_status), + "periods": data + .periods + .iter() + .take(MAX_PERIODS) + .map(compact_statement_period) + .collect::>(), + }) +} + +fn compact_cash_flow_panel(data: &CashFlowPanelData) -> Value { + json!({ + "symbol": data.symbol, + "companyName": data.company_name, + "cik": data.cik, + "frequency": data.frequency, + "latestFiling": data.latest_filing, + "sourceStatus": compact_source_status(&data.source_status), + "periods": data + .periods + .iter() + .take(MAX_PERIODS) + .map(compact_cash_flow_period) + .collect::>(), + }) +} + +fn compact_dividends_panel(data: &DividendsPanelData) -> Value { + json!({ + "symbol": data.symbol, + "companyName": data.company_name, + "cik": data.cik, + "ttmDividendsPerShare": data.ttm_dividends_per_share, + "ttmCommonDividendsPaid": data.ttm_common_dividends_paid, + "latestFiling": data.latest_filing, + "sourceStatus": compact_source_status(&data.source_status), + "latestEvent": data.latest_event.as_ref().map(compact_dividend_event), + "events": data + .events + .iter() + .take(MAX_DIVIDEND_EVENTS) + .map(compact_dividend_event) + .collect::>(), + }) +} + +fn compact_earnings_panel(data: &EarningsPanelData) -> Value { + json!({ + "symbol": data.symbol, + "companyName": data.company_name, + "cik": data.cik, + "frequency": data.frequency, + "latestFiling": data.latest_filing, + "sourceStatus": compact_source_status(&data.source_status), + "periods": data + .periods + .iter() + .take(MAX_PERIODS) + .map(compact_earnings_period) + .collect::>(), + }) +} + +fn compact_holding(holding: &Holding) -> Value { + json!({ + "symbol": holding.symbol, + "name": truncate_text(&holding.name), + "quantity": holding.quantity, + "avgCost": holding.avg_cost, + "currentPrice": holding.current_price, + "currentValue": holding.current_value, + "gainLoss": holding.gain_loss, + "gainLossPercent": holding.gain_loss_percent, + "costBasis": holding.cost_basis, + "unrealizedGain": holding.unrealized_gain, + "latestTradeAt": holding.latest_trade_at, + }) +} + +fn compact_statement_period(period: &StatementPeriod) -> Value { + json!({ + "label": truncate_text(&period.label), + "fiscalYear": period.fiscal_year, + "fiscalPeriod": period.fiscal_period, + "periodStart": period.period_start, + "periodEnd": period.period_end, + "filedDate": period.filed_date, + "form": period.form, + "revenue": period.revenue, + "grossProfit": period.gross_profit, + "operatingIncome": period.operating_income, + "netIncome": period.net_income, + "dilutedEps": period.diluted_eps, + "cashAndEquivalents": period.cash_and_equivalents, + "totalAssets": period.total_assets, + "totalLiabilities": period.total_liabilities, + "totalEquity": period.total_equity, + "sharesOutstanding": period.shares_outstanding, + }) +} + +fn compact_cash_flow_period(period: &CashFlowPeriod) -> Value { + json!({ + "label": truncate_text(&period.label), + "fiscalYear": period.fiscal_year, + "fiscalPeriod": period.fiscal_period, + "periodStart": period.period_start, + "periodEnd": period.period_end, + "filedDate": period.filed_date, + "form": period.form, + "operatingCashFlow": period.operating_cash_flow, + "investingCashFlow": period.investing_cash_flow, + "financingCashFlow": period.financing_cash_flow, + "capex": period.capex, + "freeCashFlow": period.free_cash_flow, + "endingCash": period.ending_cash, + }) +} + +fn compact_dividend_event(event: &DividendEvent) -> Value { + json!({ + "endDate": event.end_date, + "filedDate": event.filed_date, + "form": event.form, + "frequencyGuess": truncate_text(&event.frequency_guess), + "dividendPerShare": event.dividend_per_share, + "totalCashDividends": event.total_cash_dividends, + }) +} + +fn compact_earnings_period(period: &EarningsPeriod) -> Value { + json!({ + "label": truncate_text(&period.label), + "fiscalYear": period.fiscal_year, + "fiscalPeriod": period.fiscal_period, + "periodStart": period.period_start, + "periodEnd": period.period_end, + "filedDate": period.filed_date, + "form": period.form, + "revenue": period.revenue, + "netIncome": period.net_income, + "basicEps": period.basic_eps, + "dilutedEps": period.diluted_eps, + "dilutedWeightedAverageShares": period.diluted_weighted_average_shares, + "revenueYoyChangePercent": period.revenue_yoy_change_percent, + "dilutedEpsYoyChangePercent": period.diluted_eps_yoy_change_percent, + }) +} + +fn compact_source_status(source_status: &SourceStatus) -> Value { + json!({ + "companyfactsUsed": source_status.companyfacts_used, + "latestXbrlParsed": source_status.latest_xbrl_parsed, + "degradedReason": source_status.degraded_reason.as_deref().map(truncate_text), + }) +} + +fn summarize_chart_points(points: &[CompanyPricePoint]) -> Value { + let prices = points.iter().map(|point| point.price).collect::>(); + let min_price = prices.iter().copied().reduce(f64::min); + let max_price = prices.iter().copied().reduce(f64::max); + let first_point = points.first(); + let last_point = points.last(); + + json!({ + "points": points.len(), + "startLabel": first_point.map(|point| truncate_text(&point.label)), + "endLabel": last_point.map(|point| truncate_text(&point.label)), + "startTimestamp": first_point.and_then(|point| point.timestamp.clone()), + "endTimestamp": last_point.and_then(|point| point.timestamp.clone()), + "startPrice": first_point.map(|point| point.price), + "endPrice": last_point.map(|point| point.price), + "minPrice": min_price, + "maxPrice": max_price, + }) +} + +fn truncate_text_items(items: &[String]) -> Vec { + items.iter().map(|item| truncate_text(item)).collect() +} + +fn truncate_text(text: &str) -> String { + if text.chars().count() <= MAX_TEXT_FIELD_LENGTH { + return text.to_string(); + } + + let mut truncated = text + .chars() + .take(MAX_TEXT_FIELD_LENGTH.saturating_sub(3)) + .collect::(); + if MAX_TEXT_FIELD_LENGTH >= 3 { + truncated.push_str("..."); + } + truncated +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::{build_panel_context_message, compact_panel_payload, truncate_text}; + use crate::agent::ChatPanelContext; + use crate::terminal::{ + CashFlowPanelData, CashFlowPeriod, Company, CompanyProfile, DividendEvent, + DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FilingRef, + FinancialsPanelData, Frequency, Holding, NewsItem, PanelPayload, Portfolio, SourceStatus, + StatementPeriod, StockAnalysis, + }; + + #[test] + fn company_context_summarizes_chart_data_without_raw_points() { + let value = compact_panel_payload(&PanelPayload::Company { + data: sample_company(), + }); + + assert_eq!(value["symbol"], "AAPL"); + assert!(value["chartRangeSummaries"].is_array()); + assert!(value.get("priceChart").is_none()); + assert!(value.to_string().contains("\"range\":\"1D\"")); + assert!(!value.to_string().contains("\"priceChartRanges\"")); + } + + #[test] + fn news_context_caps_items_and_truncates_text() { + let value = compact_panel_payload(&PanelPayload::News { + data: (0..6).map(sample_news_item).collect(), + ticker: Some("AAPL".to_string()), + }); + + let items = value["items"].as_array().unwrap(); + assert_eq!(items.len(), 5); + assert!(items[0]["snippet"].as_str().unwrap().len() <= 603); + } + + #[test] + fn analysis_context_keeps_key_fields() { + let value = compact_panel_payload(&PanelPayload::Analysis { + data: sample_analysis(), + }); + + assert_eq!(value["symbol"], "AAPL"); + assert_eq!(value["recommendation"], "buy"); + assert_eq!(value["keyPoints"].as_array().unwrap().len(), 2); + } + + #[test] + fn financial_panels_keep_only_recent_periods() { + let financials = compact_panel_payload(&PanelPayload::Financials { + data: sample_financials(), + }); + let cash_flow = compact_panel_payload(&PanelPayload::CashFlow { + data: sample_cash_flow(), + }); + let earnings = compact_panel_payload(&PanelPayload::Earnings { + data: sample_earnings(), + }); + + assert_eq!(financials["periods"].as_array().unwrap().len(), 4); + assert_eq!(cash_flow["periods"].as_array().unwrap().len(), 4); + assert_eq!(earnings["periods"].as_array().unwrap().len(), 4); + } + + #[test] + fn dividends_context_keeps_latest_event_and_recent_history() { + let value = compact_panel_payload(&PanelPayload::Dividends { + data: sample_dividends(), + }); + + assert!(value["latestEvent"].is_object()); + assert_eq!(value["events"].as_array().unwrap().len(), 4); + } + + #[test] + fn portfolio_context_caps_holdings() { + let value = compact_panel_payload(&PanelPayload::Portfolio { + data: sample_portfolio(), + }); + + assert_eq!(value["holdings"].as_array().unwrap().len(), 25); + assert_eq!(value["summary"]["totalValue"], 1000.0); + } + + #[test] + fn error_context_keeps_expected_fields() { + let value = compact_panel_payload(&PanelPayload::Error { + data: sample_error(), + }); + + assert_eq!(value["title"], "Lookup failed"); + assert_eq!(value["symbol"], "AAPL"); + } + + #[test] + fn builds_hidden_system_message_with_metadata() { + let message = build_panel_context_message(&ChatPanelContext { + source_command: Some("/search AAPL".to_string()), + captured_at: Some("2026-04-06T10:00:00Z".to_string()), + panel: PanelPayload::Company { + data: sample_company(), + }, + }) + .unwrap(); + + let Value::String(content) = serde_json::to_value(&message) + .unwrap()["content"] + .clone() + else { + panic!("expected string content"); + }; + + assert!(content.contains("Hidden workspace panel context")); + assert!(content.contains("\"panelType\":\"company\"")); + assert!(content.contains("\"sourceCommand\":\"/search AAPL\"")); + } + + #[test] + fn truncate_text_appends_ellipsis_when_needed() { + let long_text = "x".repeat(650); + let truncated = truncate_text(&long_text); + + assert_eq!(truncated.len(), 600); + assert!(truncated.ends_with("...")); + } + + fn sample_company() -> Company { + Company { + symbol: "AAPL".to_string(), + name: "Apple Inc.".to_string(), + price: 200.0, + change: 2.5, + change_percent: 1.2, + market_cap: 3_000_000_000_000.0, + volume: Some(100_000), + volume_label: Some("Volume".to_string()), + pe: Some(30.0), + eps: Some(6.5), + high52_week: Some(210.0), + low52_week: Some(150.0), + profile: Some(CompanyProfile { + description: Some("A".repeat(650)), + wiki_url: Some("https://example.com".to_string()), + ceo: Some("Tim Cook".to_string()), + headquarters: Some("Cupertino".to_string()), + employees: Some(10), + founded: Some(1976), + sector: Some("Technology".to_string()), + website: Some("https://apple.com".to_string()), + }), + price_chart: Some(vec![ + sample_chart_point("Open", 195.0, Some("2026-04-06T09:30:00Z")), + sample_chart_point("Close", 200.0, Some("2026-04-06T16:00:00Z")), + ]), + price_chart_ranges: Some( + [( + "1D".to_string(), + vec![ + sample_chart_point("09:30", 195.0, Some("2026-04-06T09:30:00Z")), + sample_chart_point("16:00", 200.0, Some("2026-04-06T16:00:00Z")), + ], + )] + .into_iter() + .collect(), + ), + } + } + + fn sample_news_item(index: usize) -> NewsItem { + NewsItem { + id: format!("news-{index}"), + source: "Source".to_string(), + headline: format!("Headline {index}"), + timestamp: "2026-04-06T10:00:00Z".to_string(), + snippet: "S".repeat(650), + url: Some("https://example.com/story".to_string()), + related_tickers: vec!["AAPL".to_string()], + } + } + + fn sample_analysis() -> StockAnalysis { + StockAnalysis { + symbol: "AAPL".to_string(), + summary: "Strong execution".to_string(), + sentiment: "bullish".to_string(), + key_points: vec!["Services growth".to_string(), "Margin expansion".to_string()], + risks: vec!["China demand".to_string()], + opportunities: vec!["AI features".to_string()], + recommendation: "buy".to_string(), + target_price: Some(225.0), + } + } + + fn sample_financials() -> FinancialsPanelData { + FinancialsPanelData { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + cik: "0000320193".to_string(), + frequency: Frequency::Annual, + periods: (0..5).map(sample_statement_period).collect(), + latest_filing: Some(sample_filing_ref()), + source_status: sample_source_status(), + } + } + + fn sample_cash_flow() -> CashFlowPanelData { + CashFlowPanelData { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + cik: "0000320193".to_string(), + frequency: Frequency::Annual, + periods: (0..5).map(sample_cash_flow_period).collect(), + latest_filing: Some(sample_filing_ref()), + source_status: sample_source_status(), + } + } + + fn sample_dividends() -> DividendsPanelData { + DividendsPanelData { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + cik: "0000320193".to_string(), + ttm_dividends_per_share: Some(1.0), + ttm_common_dividends_paid: Some(10.0), + latest_event: Some(sample_dividend_event(0)), + events: (0..5).map(sample_dividend_event).collect(), + latest_filing: Some(sample_filing_ref()), + source_status: sample_source_status(), + } + } + + fn sample_earnings() -> EarningsPanelData { + EarningsPanelData { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + cik: "0000320193".to_string(), + frequency: Frequency::Quarterly, + periods: (0..5).map(sample_earnings_period).collect(), + latest_filing: Some(sample_filing_ref()), + source_status: sample_source_status(), + } + } + + fn sample_portfolio() -> Portfolio { + Portfolio { + holdings: (0..30) + .map(|index| Holding { + symbol: format!("SYM{index}"), + name: format!("Holding {index}"), + quantity: 1.0, + avg_cost: 10.0, + current_price: 20.0, + current_value: 20.0, + gain_loss: 10.0, + gain_loss_percent: 100.0, + cost_basis: Some(10.0), + unrealized_gain: Some(10.0), + latest_trade_at: Some("2026-04-06T10:00:00Z".to_string()), + }) + .collect(), + total_value: 1000.0, + day_change: 10.0, + day_change_percent: 1.0, + total_gain: 100.0, + total_gain_percent: 10.0, + cash_balance: Some(100.0), + invested_cost_basis: Some(900.0), + realized_gain: Some(50.0), + unrealized_gain: Some(50.0), + holdings_count: Some(30), + stale_pricing_symbols: Some(vec!["SYM1".to_string()]), + } + } + + fn sample_error() -> ErrorPanel { + ErrorPanel { + title: "Lookup failed".to_string(), + message: "The lookup failed".to_string(), + detail: Some("detail".to_string()), + provider: Some("provider".to_string()), + query: Some("apple".to_string()), + symbol: Some("AAPL".to_string()), + } + } + + fn sample_source_status() -> SourceStatus { + SourceStatus { + companyfacts_used: true, + latest_xbrl_parsed: true, + degraded_reason: None, + } + } + + fn sample_filing_ref() -> FilingRef { + FilingRef { + accession_number: "0000000000".to_string(), + filing_date: "2026-04-01".to_string(), + report_date: Some("2026-03-31".to_string()), + form: "10-K".to_string(), + primary_document: Some("doc.htm".to_string()), + } + } + + fn sample_statement_period(index: usize) -> StatementPeriod { + StatementPeriod { + label: format!("FY{index}"), + fiscal_year: Some(format!("20{index:02}")), + fiscal_period: Some("FY".to_string()), + period_start: Some("2025-01-01".to_string()), + period_end: "2025-12-31".to_string(), + filed_date: "2026-01-31".to_string(), + form: "10-K".to_string(), + revenue: Some(100.0), + gross_profit: Some(50.0), + operating_income: Some(40.0), + net_income: Some(30.0), + diluted_eps: Some(2.0), + cash_and_equivalents: Some(20.0), + total_assets: Some(10.0), + total_liabilities: Some(5.0), + total_equity: Some(5.0), + shares_outstanding: Some(1.0), + } + } + + fn sample_cash_flow_period(index: usize) -> CashFlowPeriod { + CashFlowPeriod { + label: format!("FY{index}"), + fiscal_year: Some(format!("20{index:02}")), + fiscal_period: Some("FY".to_string()), + period_start: Some("2025-01-01".to_string()), + period_end: "2025-12-31".to_string(), + filed_date: "2026-01-31".to_string(), + form: "10-K".to_string(), + operating_cash_flow: Some(100.0), + investing_cash_flow: Some(-50.0), + financing_cash_flow: Some(-25.0), + capex: Some(-10.0), + free_cash_flow: Some(90.0), + ending_cash: Some(20.0), + } + } + + fn sample_dividend_event(index: usize) -> DividendEvent { + DividendEvent { + end_date: format!("2026-03-0{}", index + 1), + filed_date: "2026-03-15".to_string(), + form: "10-Q".to_string(), + frequency_guess: "Quarterly".to_string(), + dividend_per_share: Some(0.25), + total_cash_dividends: Some(100.0), + } + } + + fn sample_earnings_period(index: usize) -> EarningsPeriod { + EarningsPeriod { + label: format!("Q{index}"), + fiscal_year: Some("2026".to_string()), + fiscal_period: Some("Q1".to_string()), + period_start: Some("2026-01-01".to_string()), + period_end: "2026-03-31".to_string(), + filed_date: "2026-04-30".to_string(), + form: "10-Q".to_string(), + revenue: Some(100.0), + net_income: Some(30.0), + basic_eps: Some(1.0), + diluted_eps: Some(0.9), + diluted_weighted_average_shares: Some(100.0), + revenue_yoy_change_percent: Some(5.0), + diluted_eps_yoy_change_percent: Some(2.0), + } + } + + fn sample_chart_point(label: &str, price: f64, timestamp: Option<&str>) -> crate::terminal::CompanyPricePoint { + crate::terminal::CompanyPricePoint { + label: label.to_string(), + price, + volume: Some(10), + timestamp: timestamp.map(str::to_string), + } + } +} diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index 17e4730..dedca88 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -6,9 +6,9 @@ use rig::completion::Message; use tauri::{AppHandle, Runtime}; use crate::agent::{ - AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest, - PreparedChatTurn, RemoteProviderSettings, RigChatGateway, SaveAgentRuntimeConfigRequest, - TaskProfile, UpdateRemoteApiKeyRequest, + panel_context::build_panel_context_message, AgentConfigStatus, AgentRuntimeConfig, + AgentStoredSettings, ChatPromptRequest, PreparedChatTurn, RemoteProviderSettings, + RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, }; use crate::error::AppError; @@ -50,6 +50,7 @@ impl SessionManager { session_id, prompt: prompt.to_string(), history: prior_history, + context_messages: Vec::new(), runtime, }) } @@ -96,9 +97,18 @@ impl AgentService { &mut self, request: ChatPromptRequest, ) -> Result { + let panel_context = request.panel_context.clone(); let runtime = self.resolve_runtime(request.agent_profile, request.model_override.clone())?; - self.session_manager.prepare_turn(request, runtime) + let mut prepared_turn = self.session_manager.prepare_turn(request, runtime)?; + prepared_turn.context_messages = panel_context + .as_ref() + .map(build_panel_context_message) + .transpose()? + .into_iter() + .collect(); + + Ok(prepared_turn) } pub fn record_assistant_reply( @@ -194,10 +204,11 @@ mod tests { use super::SessionManager; use crate::agent::{ - default_task_defaults, AgentRuntimeConfig, AgentService, ChatPromptRequest, - SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, - DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, + default_task_defaults, AgentRuntimeConfig, AgentService, ChatPanelContext, + ChatPromptRequest, SaveAgentRuntimeConfigRequest, TaskProfile, + UpdateRemoteApiKeyRequest, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; + use crate::terminal::{Company, CompanyProfile, PanelPayload}; use crate::error::AppError; use rig::completion::Message; @@ -214,6 +225,7 @@ mod tests { prompt: " ".to_string(), agent_profile: None, model_override: None, + panel_context: None, }, sample_runtime(), ); @@ -233,6 +245,7 @@ mod tests { prompt: "Summarize AAPL".to_string(), agent_profile: None, model_override: None, + panel_context: None, }, sample_runtime(), ) @@ -255,6 +268,7 @@ mod tests { prompt: "First prompt".to_string(), agent_profile: None, model_override: None, + panel_context: None, }, sample_runtime(), ) @@ -271,6 +285,7 @@ mod tests { prompt: "Second prompt".to_string(), agent_profile: None, model_override: None, + panel_context: None, }, sample_runtime(), ) @@ -323,6 +338,7 @@ mod tests { prompt: "hello".to_string(), agent_profile: None, model_override: None, + panel_context: None, }) .unwrap(); assert_eq!(prepared.runtime.base_url, "https://example.test/v4"); @@ -363,6 +379,7 @@ mod tests { prompt: "hello".to_string(), agent_profile: None, model_override: None, + panel_context: None, }); assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); }); @@ -395,6 +412,7 @@ mod tests { prompt: "hello".to_string(), agent_profile: None, model_override: Some("glm-override".to_string()), + panel_context: None, }) .unwrap(); @@ -453,12 +471,97 @@ mod tests { prompt: "hello".to_string(), agent_profile: None, model_override: None, + panel_context: None, }); assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); }); } + #[test] + fn prepare_turn_without_panel_context_yields_no_context_messages() { + with_test_home("context-none", || { + let app = build_test_app(); + let mut service = configured_service(&app); + + let prepared = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + panel_context: None, + }) + .unwrap(); + + assert!(prepared.context_messages.is_empty()); + }); + } + + #[test] + fn prepare_turn_with_panel_context_yields_one_system_context_message() { + with_test_home("context-present", || { + let app = build_test_app(); + let mut service = configured_service(&app); + + let prepared = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + panel_context: Some(sample_panel_context()), + }) + .unwrap(); + + assert_eq!(prepared.context_messages.len(), 1); + assert!(matches!(prepared.context_messages[0], Message::System { .. })); + }); + } + + #[test] + fn panel_context_is_not_persisted_in_follow_up_history() { + with_test_home("context-history", || { + let app = build_test_app(); + let mut service = configured_service(&app); + + let first = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + panel_context: Some(sample_panel_context()), + }) + .unwrap(); + + assert_eq!(first.context_messages.len(), 1); + + service + .record_assistant_reply(&first.session_id, "reply") + .unwrap(); + + let follow_up = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: Some(first.session_id), + prompt: "follow up".to_string(), + agent_profile: None, + model_override: None, + panel_context: None, + }) + .unwrap(); + + assert!(follow_up.context_messages.is_empty()); + assert_eq!(follow_up.history.len(), 2); + assert_eq!(follow_up.history[0], Message::user("hello")); + assert_eq!(follow_up.history[1], Message::assistant("reply")); + }); + } + fn sample_runtime() -> AgentRuntimeConfig { AgentRuntimeConfig { base_url: "https://example.com".to_string(), @@ -468,6 +571,41 @@ mod tests { } } + fn sample_panel_context() -> ChatPanelContext { + ChatPanelContext { + source_command: Some("/search AAPL".to_string()), + captured_at: Some("2026-04-06T10:00:00Z".to_string()), + panel: PanelPayload::Company { + data: Company { + symbol: "AAPL".to_string(), + name: "Apple Inc.".to_string(), + price: 200.0, + change: 1.0, + change_percent: 0.5, + market_cap: 3_000_000_000_000.0, + volume: Some(100), + volume_label: Some("Volume".to_string()), + pe: Some(30.0), + eps: Some(6.0), + high52_week: Some(210.0), + low52_week: Some(150.0), + profile: Some(CompanyProfile { + description: Some("Description".to_string()), + wiki_url: None, + ceo: Some("Tim Cook".to_string()), + headquarters: Some("Cupertino".to_string()), + employees: Some(10), + founded: Some(1976), + sector: Some("Technology".to_string()), + website: Some("https://apple.com".to_string()), + }), + price_chart: None, + price_chart_ranges: None, + }, + }, + } + } + fn build_test_app() -> tauri::App { mock_builder() .plugin(tauri_plugin_store::Builder::new().build()) @@ -475,6 +613,25 @@ mod tests { .unwrap() } + fn configured_service(app: &tauri::App) -> AgentService { + let mut service = AgentService::new(&app.handle()).unwrap(); + service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + task_defaults: default_task_defaults("glm-test"), + sec_edgar_user_agent: String::new(), + }) + .unwrap(); + service + .update_remote_api_key(UpdateRemoteApiKeyRequest { + api_key: "z-ai-key-1".to_string(), + }) + .unwrap(); + service + } + fn unique_identifier(prefix: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index dac000a..8b8e7cc 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -2,6 +2,8 @@ use rig::completion::Message; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::terminal::PanelPayload; + /// Default Z.AI coding plan endpoint used by the app. pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; /// Default model used for plain-text terminal chat. @@ -28,6 +30,15 @@ pub struct ChatPromptRequest { pub prompt: String, pub agent_profile: Option, pub model_override: Option, + pub panel_context: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatPanelContext { + pub source_command: Option, + pub captured_at: Option, + pub panel: PanelPayload, } /// Runtime provider configuration after settings resolution. @@ -46,6 +57,7 @@ pub struct PreparedChatTurn { pub session_id: String, pub prompt: String, pub history: Vec, + pub context_messages: Vec, pub runtime: AgentRuntimeConfig, } diff --git a/MosaicIQ/src-tauri/src/commands/terminal.rs b/MosaicIQ/src-tauri/src/commands/terminal.rs index 52eef6e..3d4e4b7 100644 --- a/MosaicIQ/src-tauri/src/commands/terminal.rs +++ b/MosaicIQ/src-tauri/src/commands/terminal.rs @@ -66,6 +66,7 @@ pub async fn start_chat_stream( .stream_chat( prepared_turn.runtime.clone(), prepared_turn.prompt.clone(), + prepared_turn.context_messages.clone(), prepared_turn.history.clone(), ) .await diff --git a/MosaicIQ/src-tauri/src/error.rs b/MosaicIQ/src-tauri/src/error.rs index 1f198ad..9f97ecb 100644 --- a/MosaicIQ/src-tauri/src/error.rs +++ b/MosaicIQ/src-tauri/src/error.rs @@ -10,6 +10,7 @@ pub enum AppError { AgentNotConfigured, RemoteApiKeyMissing, InvalidSettings(String), + PanelContext(String), UnknownSession(String), SettingsStore(String), ProviderInit(String), @@ -28,6 +29,9 @@ impl Display for AppError { ), Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"), Self::InvalidSettings(message) => formatter.write_str(message), + Self::PanelContext(message) => { + write!(formatter, "panel context could not be prepared: {message}") + } Self::UnknownSession(session_id) => { write!(formatter, "unknown session: {session_id}") } diff --git a/MosaicIQ/src-tauri/src/terminal/mod.rs b/MosaicIQ/src-tauri/src/terminal/mod.rs index 0bd39e3..12fb2ef 100644 --- a/MosaicIQ/src-tauri/src/terminal/mod.rs +++ b/MosaicIQ/src-tauri/src/terminal/mod.rs @@ -10,6 +10,6 @@ pub use types::{ CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint, CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding, - LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus, - StatementPeriod, TerminalCommandResponse, + LookupCompanyRequest, MockFinancialData, NewsItem, PanelPayload, Portfolio, SourceStatus, + StatementPeriod, StockAnalysis, TerminalCommandResponse, }; diff --git a/MosaicIQ/src-tauri/src/terminal/types.rs b/MosaicIQ/src-tauri/src/terminal/types.rs index 736ebb9..85745a0 100644 --- a/MosaicIQ/src-tauri/src/terminal/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/types.rs @@ -46,7 +46,7 @@ pub enum TerminalCommandResponse { } /// Serializable panel variants shared between Rust and React. -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PanelPayload { Company { diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index fae05fb..ca996a5 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -12,6 +12,7 @@ import { useTabs } from './hooks/useTabs'; import { useTickerHistory } from './hooks/useTickerHistory'; import { createEntry } from './hooks/useTerminal'; import { agentSettingsBridge } from './lib/agentSettingsBridge'; +import { extractChatPanelContext } from './lib/chatPanelContext'; import { extractTickerSymbolFromResponse, resolveTickerCommandFallback, @@ -186,6 +187,7 @@ function App() { } // Plain text keeps the current workspace conversation alive and streams into a placeholder response entry. + const panelContext = extractChatPanelContext(currentWorkspace?.history ?? []); const commandEntry = createEntry({ type: 'command', content: resolvedCommand }); const responseEntry = createEntry({ type: 'response', content: '' }); @@ -199,6 +201,7 @@ function App() { sessionId: currentWorkspace?.chatSessionId, prompt: resolvedCommand, agentProfile: 'interactiveChat', + panelContext, }, { onDelta: (event) => { diff --git a/MosaicIQ/src/lib/chatPanelContext.ts b/MosaicIQ/src/lib/chatPanelContext.ts new file mode 100644 index 0000000..86ea3e1 --- /dev/null +++ b/MosaicIQ/src/lib/chatPanelContext.ts @@ -0,0 +1,52 @@ +import { + ChatPanelContext, + PanelPayload, + TerminalEntry, + TransportPanelPayload, +} from '../types/terminal'; + +const toTransportPanelPayload = (panel: PanelPayload): TransportPanelPayload => { + if (panel.type !== 'news') { + return panel; + } + + return { + ...panel, + data: panel.data.map((item) => ({ + ...item, + timestamp: item.timestamp.toISOString(), + })), + }; +}; + +export const extractChatPanelContext = ( + history: TerminalEntry[], +): ChatPanelContext | undefined => { + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (entry.type !== 'panel' || typeof entry.content === 'string') { + continue; + } + + let sourceCommand: string | undefined; + for (let commandIndex = index - 1; commandIndex >= 0; commandIndex -= 1) { + const candidate = history[commandIndex]; + if ( + candidate.type === 'command' && + typeof candidate.content === 'string' && + candidate.content.startsWith('/') + ) { + sourceCommand = candidate.content; + break; + } + } + + return { + sourceCommand, + capturedAt: entry.timestamp?.toISOString(), + panel: toTransportPanelPayload(entry.content), + }; + } + + return undefined; +}; diff --git a/MosaicIQ/src/types/terminal.ts b/MosaicIQ/src/types/terminal.ts index 1b4a6b4..1bf5064 100644 --- a/MosaicIQ/src/types/terminal.ts +++ b/MosaicIQ/src/types/terminal.ts @@ -41,6 +41,12 @@ export type ResolvedTerminalCommandResponse = | { kind: 'text'; content: string; portfolio?: Portfolio } | { kind: 'panel'; panel: PanelPayload }; +export interface ChatPanelContext { + sourceCommand?: string; + capturedAt?: string; + panel: TransportPanelPayload; +} + export interface ExecuteTerminalCommandRequest { workspaceId: string; input: string; @@ -56,6 +62,7 @@ export interface StartChatStreamRequest { prompt: string; agentProfile?: TaskProfile; modelOverride?: string; + panelContext?: ChatPanelContext; } export interface ChatStreamStart {