Feed active panel context into agent chat
This commit is contained in:
@@ -23,6 +23,7 @@ pub trait ChatGateway: Clone + Send + Sync + 'static {
|
|||||||
&self,
|
&self,
|
||||||
runtime: AgentRuntimeConfig,
|
runtime: AgentRuntimeConfig,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
context_messages: Vec<Message>,
|
||||||
history: Vec<Message>,
|
history: Vec<Message>,
|
||||||
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>;
|
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>;
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ impl ChatGateway for RigChatGateway {
|
|||||||
&self,
|
&self,
|
||||||
runtime: AgentRuntimeConfig,
|
runtime: AgentRuntimeConfig,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
context_messages: Vec<Message>,
|
||||||
history: Vec<Message>,
|
history: Vec<Message>,
|
||||||
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
|
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
@@ -51,7 +53,7 @@ impl ChatGateway for RigChatGateway {
|
|||||||
|
|
||||||
let upstream = model
|
let upstream = model
|
||||||
.completion_request(Message::user(prompt))
|
.completion_request(Message::user(prompt))
|
||||||
.messages(history)
|
.messages(compose_request_messages(context_messages, history))
|
||||||
.preamble(SYSTEM_PROMPT.to_string())
|
.preamble(SYSTEM_PROMPT.to_string())
|
||||||
.temperature(0.2)
|
.temperature(0.2)
|
||||||
.stream()
|
.stream()
|
||||||
@@ -71,3 +73,29 @@ impl ChatGateway for RigChatGateway {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compose_request_messages(
|
||||||
|
context_messages: Vec<Message>,
|
||||||
|
history: Vec<Message>,
|
||||||
|
) -> Vec<Message> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Agent domain logic and request/response types.
|
//! Agent domain logic and request/response types.
|
||||||
|
|
||||||
mod gateway;
|
mod gateway;
|
||||||
|
mod panel_context;
|
||||||
mod routing;
|
mod routing;
|
||||||
mod service;
|
mod service;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -11,8 +12,8 @@ pub use service::AgentService;
|
|||||||
pub(crate) use settings::AgentSettingsService;
|
pub(crate) use settings::AgentSettingsService;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent,
|
default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent,
|
||||||
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart,
|
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPanelContext,
|
||||||
PreparedChatTurn, RemoteProviderSettings, SaveAgentRuntimeConfigRequest, TaskProfile,
|
ChatPromptRequest, ChatStreamStart, PreparedChatTurn, RemoteProviderSettings,
|
||||||
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL,
|
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
|
||||||
DEFAULT_REMOTE_MODEL,
|
AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
|
||||||
};
|
};
|
||||||
|
|||||||
773
MosaicIQ/src-tauri/src/agent/panel_context.rs
Normal file
773
MosaicIQ/src-tauri/src/agent/panel_context.rs
Normal file
@@ -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<Message, AppError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||||
|
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(range, points)| {
|
||||||
|
json!({
|
||||||
|
"range": range,
|
||||||
|
"summary": summarize_chart_points(points),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
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::<String>();
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ use rig::completion::Message;
|
|||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest,
|
panel_context::build_panel_context_message, AgentConfigStatus, AgentRuntimeConfig,
|
||||||
PreparedChatTurn, RemoteProviderSettings, RigChatGateway, SaveAgentRuntimeConfigRequest,
|
AgentStoredSettings, ChatPromptRequest, PreparedChatTurn, RemoteProviderSettings,
|
||||||
TaskProfile, UpdateRemoteApiKeyRequest,
|
RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ impl SessionManager {
|
|||||||
session_id,
|
session_id,
|
||||||
prompt: prompt.to_string(),
|
prompt: prompt.to_string(),
|
||||||
history: prior_history,
|
history: prior_history,
|
||||||
|
context_messages: Vec::new(),
|
||||||
runtime,
|
runtime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -96,9 +97,18 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
request: ChatPromptRequest,
|
request: ChatPromptRequest,
|
||||||
) -> Result<PreparedChatTurn, AppError> {
|
) -> Result<PreparedChatTurn, AppError> {
|
||||||
|
let panel_context = request.panel_context.clone();
|
||||||
let runtime =
|
let runtime =
|
||||||
self.resolve_runtime(request.agent_profile, request.model_override.clone())?;
|
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(
|
pub fn record_assistant_reply(
|
||||||
@@ -194,10 +204,11 @@ mod tests {
|
|||||||
|
|
||||||
use super::SessionManager;
|
use super::SessionManager;
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
default_task_defaults, AgentRuntimeConfig, AgentService, ChatPromptRequest,
|
default_task_defaults, AgentRuntimeConfig, AgentService, ChatPanelContext,
|
||||||
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
|
ChatPromptRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
|
||||||
DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
|
UpdateRemoteApiKeyRequest, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
|
||||||
};
|
};
|
||||||
|
use crate::terminal::{Company, CompanyProfile, PanelPayload};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
use rig::completion::Message;
|
use rig::completion::Message;
|
||||||
@@ -214,6 +225,7 @@ mod tests {
|
|||||||
prompt: " ".to_string(),
|
prompt: " ".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
},
|
},
|
||||||
sample_runtime(),
|
sample_runtime(),
|
||||||
);
|
);
|
||||||
@@ -233,6 +245,7 @@ mod tests {
|
|||||||
prompt: "Summarize AAPL".to_string(),
|
prompt: "Summarize AAPL".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
},
|
},
|
||||||
sample_runtime(),
|
sample_runtime(),
|
||||||
)
|
)
|
||||||
@@ -255,6 +268,7 @@ mod tests {
|
|||||||
prompt: "First prompt".to_string(),
|
prompt: "First prompt".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
},
|
},
|
||||||
sample_runtime(),
|
sample_runtime(),
|
||||||
)
|
)
|
||||||
@@ -271,6 +285,7 @@ mod tests {
|
|||||||
prompt: "Second prompt".to_string(),
|
prompt: "Second prompt".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
},
|
},
|
||||||
sample_runtime(),
|
sample_runtime(),
|
||||||
)
|
)
|
||||||
@@ -323,6 +338,7 @@ mod tests {
|
|||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(prepared.runtime.base_url, "https://example.test/v4");
|
assert_eq!(prepared.runtime.base_url, "https://example.test/v4");
|
||||||
@@ -363,6 +379,7 @@ mod tests {
|
|||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
});
|
});
|
||||||
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
||||||
});
|
});
|
||||||
@@ -395,6 +412,7 @@ mod tests {
|
|||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: Some("glm-override".to_string()),
|
model_override: Some("glm-override".to_string()),
|
||||||
|
panel_context: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -453,12 +471,97 @@ mod tests {
|
|||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
agent_profile: None,
|
agent_profile: None,
|
||||||
model_override: None,
|
model_override: None,
|
||||||
|
panel_context: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
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 {
|
fn sample_runtime() -> AgentRuntimeConfig {
|
||||||
AgentRuntimeConfig {
|
AgentRuntimeConfig {
|
||||||
base_url: "https://example.com".to_string(),
|
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<MockRuntime> {
|
fn build_test_app() -> tauri::App<MockRuntime> {
|
||||||
mock_builder()
|
mock_builder()
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
@@ -475,6 +613,25 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn configured_service(app: &tauri::App<MockRuntime>) -> AgentService<MockRuntime> {
|
||||||
|
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 {
|
fn unique_identifier(prefix: &str) -> String {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use rig::completion::Message;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::terminal::PanelPayload;
|
||||||
|
|
||||||
/// Default Z.AI coding plan endpoint used by the app.
|
/// 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";
|
pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||||
/// Default model used for plain-text terminal chat.
|
/// Default model used for plain-text terminal chat.
|
||||||
@@ -28,6 +30,15 @@ pub struct ChatPromptRequest {
|
|||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
pub agent_profile: Option<TaskProfile>,
|
pub agent_profile: Option<TaskProfile>,
|
||||||
pub model_override: Option<String>,
|
pub model_override: Option<String>,
|
||||||
|
pub panel_context: Option<ChatPanelContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChatPanelContext {
|
||||||
|
pub source_command: Option<String>,
|
||||||
|
pub captured_at: Option<String>,
|
||||||
|
pub panel: PanelPayload,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime provider configuration after settings resolution.
|
/// Runtime provider configuration after settings resolution.
|
||||||
@@ -46,6 +57,7 @@ pub struct PreparedChatTurn {
|
|||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
pub history: Vec<Message>,
|
pub history: Vec<Message>,
|
||||||
|
pub context_messages: Vec<Message>,
|
||||||
pub runtime: AgentRuntimeConfig,
|
pub runtime: AgentRuntimeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub async fn start_chat_stream(
|
|||||||
.stream_chat(
|
.stream_chat(
|
||||||
prepared_turn.runtime.clone(),
|
prepared_turn.runtime.clone(),
|
||||||
prepared_turn.prompt.clone(),
|
prepared_turn.prompt.clone(),
|
||||||
|
prepared_turn.context_messages.clone(),
|
||||||
prepared_turn.history.clone(),
|
prepared_turn.history.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub enum AppError {
|
|||||||
AgentNotConfigured,
|
AgentNotConfigured,
|
||||||
RemoteApiKeyMissing,
|
RemoteApiKeyMissing,
|
||||||
InvalidSettings(String),
|
InvalidSettings(String),
|
||||||
|
PanelContext(String),
|
||||||
UnknownSession(String),
|
UnknownSession(String),
|
||||||
SettingsStore(String),
|
SettingsStore(String),
|
||||||
ProviderInit(String),
|
ProviderInit(String),
|
||||||
@@ -28,6 +29,9 @@ impl Display for AppError {
|
|||||||
),
|
),
|
||||||
Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"),
|
Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"),
|
||||||
Self::InvalidSettings(message) => formatter.write_str(message),
|
Self::InvalidSettings(message) => formatter.write_str(message),
|
||||||
|
Self::PanelContext(message) => {
|
||||||
|
write!(formatter, "panel context could not be prepared: {message}")
|
||||||
|
}
|
||||||
Self::UnknownSession(session_id) => {
|
Self::UnknownSession(session_id) => {
|
||||||
write!(formatter, "unknown session: {session_id}")
|
write!(formatter, "unknown session: {session_id}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ pub use types::{
|
|||||||
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
|
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
|
||||||
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
|
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
|
||||||
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding,
|
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding,
|
||||||
LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus,
|
LookupCompanyRequest, MockFinancialData, NewsItem, PanelPayload, Portfolio, SourceStatus,
|
||||||
StatementPeriod, TerminalCommandResponse,
|
StatementPeriod, StockAnalysis, TerminalCommandResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub enum TerminalCommandResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serializable panel variants shared between Rust and React.
|
/// 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")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
pub enum PanelPayload {
|
pub enum PanelPayload {
|
||||||
Company {
|
Company {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTabs } from './hooks/useTabs';
|
|||||||
import { useTickerHistory } from './hooks/useTickerHistory';
|
import { useTickerHistory } from './hooks/useTickerHistory';
|
||||||
import { createEntry } from './hooks/useTerminal';
|
import { createEntry } from './hooks/useTerminal';
|
||||||
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
||||||
|
import { extractChatPanelContext } from './lib/chatPanelContext';
|
||||||
import {
|
import {
|
||||||
extractTickerSymbolFromResponse,
|
extractTickerSymbolFromResponse,
|
||||||
resolveTickerCommandFallback,
|
resolveTickerCommandFallback,
|
||||||
@@ -186,6 +187,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plain text keeps the current workspace conversation alive and streams into a placeholder response entry.
|
// 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 commandEntry = createEntry({ type: 'command', content: resolvedCommand });
|
||||||
const responseEntry = createEntry({ type: 'response', content: '' });
|
const responseEntry = createEntry({ type: 'response', content: '' });
|
||||||
|
|
||||||
@@ -199,6 +201,7 @@ function App() {
|
|||||||
sessionId: currentWorkspace?.chatSessionId,
|
sessionId: currentWorkspace?.chatSessionId,
|
||||||
prompt: resolvedCommand,
|
prompt: resolvedCommand,
|
||||||
agentProfile: 'interactiveChat',
|
agentProfile: 'interactiveChat',
|
||||||
|
panelContext,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onDelta: (event) => {
|
onDelta: (event) => {
|
||||||
|
|||||||
52
MosaicIQ/src/lib/chatPanelContext.ts
Normal file
52
MosaicIQ/src/lib/chatPanelContext.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -41,6 +41,12 @@ export type ResolvedTerminalCommandResponse =
|
|||||||
| { kind: 'text'; content: string; portfolio?: Portfolio }
|
| { kind: 'text'; content: string; portfolio?: Portfolio }
|
||||||
| { kind: 'panel'; panel: PanelPayload };
|
| { kind: 'panel'; panel: PanelPayload };
|
||||||
|
|
||||||
|
export interface ChatPanelContext {
|
||||||
|
sourceCommand?: string;
|
||||||
|
capturedAt?: string;
|
||||||
|
panel: TransportPanelPayload;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecuteTerminalCommandRequest {
|
export interface ExecuteTerminalCommandRequest {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
input: string;
|
input: string;
|
||||||
@@ -56,6 +62,7 @@ export interface StartChatStreamRequest {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
agentProfile?: TaskProfile;
|
agentProfile?: TaskProfile;
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
|
panelContext?: ChatPanelContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatStreamStart {
|
export interface ChatStreamStart {
|
||||||
|
|||||||
Reference in New Issue
Block a user