Feed active panel context into agent chat
This commit is contained in:
@@ -23,6 +23,7 @@ pub trait ChatGateway: Clone + Send + Sync + 'static {
|
||||
&self,
|
||||
runtime: AgentRuntimeConfig,
|
||||
prompt: String,
|
||||
context_messages: Vec<Message>,
|
||||
history: Vec<Message>,
|
||||
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>;
|
||||
}
|
||||
@@ -36,6 +37,7 @@ impl ChatGateway for RigChatGateway {
|
||||
&self,
|
||||
runtime: AgentRuntimeConfig,
|
||||
prompt: String,
|
||||
context_messages: Vec<Message>,
|
||||
history: Vec<Message>,
|
||||
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
|
||||
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<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.
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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 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<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
||||
&mut self,
|
||||
request: ChatPromptRequest,
|
||||
) -> Result<PreparedChatTurn, AppError> {
|
||||
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<MockRuntime> {
|
||||
mock_builder()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
@@ -475,6 +613,25 @@ mod tests {
|
||||
.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 {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -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<TaskProfile>,
|
||||
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.
|
||||
@@ -46,6 +57,7 @@ pub struct PreparedChatTurn {
|
||||
pub session_id: String,
|
||||
pub prompt: String,
|
||||
pub history: Vec<Message>,
|
||||
pub context_messages: Vec<Message>,
|
||||
pub runtime: AgentRuntimeConfig,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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: '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 {
|
||||
|
||||
Reference in New Issue
Block a user