Skip to content

Commit cf48144

Browse files
authored
feat(widgets): add Exa web search + fix widget API endpoints (koala73#1782)
* feat(widgets): add Exa web search + fix widget API endpoints - Replace Tavily with Exa as primary stock-news search provider (Exa → Brave → SerpAPI → Google News RSS cascade) - Add search_web tool to widget agent so AI can fetch live data about any topic beyond the pre-defined RPC catalog - Exa primary (type:auto + content snippets), Brave fallback - Fix all widget tool endpoints: /rpc/... paths were hitting Vercel catch-all and returning SPA HTML instead of JSON data - Fix wm-widget-shell min-height causing fixed-size border that clipped AI widget content - Add HTML response guard in tool handler - Update env key: TAVILY_API_KEYS → EXA_API_KEYS throughout * fix(stock-news): use type 'neural' for Exa search (type 'news' is invalid)
1 parent 3ba5699 commit cf48144

8 files changed

Lines changed: 201 additions & 90 deletions

File tree

scripts/ais-relay.cjs

Lines changed: 161 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7463,19 +7463,20 @@ const server = http.createServer(async (req, res) => {
74637463
// ─── Widget Agent ────────────────────────────────────────────────────────────
74647464

74657465
const WIDGET_ALLOWED_ENDPOINTS = new Set([
7466-
'/rpc/worldmonitor.economic.v1.EconomicService/GetIndicators',
7467-
'/rpc/worldmonitor.trade.v1.TradeService/GetCustomsRevenue',
7468-
'/rpc/worldmonitor.trade.v1.TradeService/GetTradeRestrictions',
7469-
'/rpc/worldmonitor.trade.v1.TradeService/GetTariffTrends',
7470-
'/rpc/worldmonitor.trade.v1.TradeService/GetTradeFlows',
7471-
'/rpc/worldmonitor.trade.v1.TradeService/GetTradeBarriers',
7472-
'/rpc/worldmonitor.markets.v1.MarketsService/GetQuotes',
7473-
'/rpc/worldmonitor.markets.v1.MarketsService/GetSectors',
7474-
'/rpc/worldmonitor.markets.v1.MarketsService/GetCommodities',
7475-
'/rpc/worldmonitor.markets.v1.MarketsService/GetCryptoQuotes',
7476-
'/rpc/worldmonitor.aviation.v1.AviationService/GetFlightDelays',
7477-
'/rpc/worldmonitor.cii.v1.CiiService/GetCiiScores',
7478-
'/rpc/worldmonitor.ucdp.v1.UcdpService/GetEvents',
7466+
'/api/economic/v1/list-world-bank-indicators',
7467+
'/api/economic/v1/get-macro-signals',
7468+
'/api/trade/v1/get-customs-revenue',
7469+
'/api/trade/v1/get-trade-restrictions',
7470+
'/api/trade/v1/get-tariff-trends',
7471+
'/api/trade/v1/get-trade-flows',
7472+
'/api/trade/v1/get-trade-barriers',
7473+
'/api/market/v1/list-market-quotes',
7474+
'/api/market/v1/get-sector-summary',
7475+
'/api/market/v1/list-commodity-quotes',
7476+
'/api/market/v1/list-crypto-quotes',
7477+
'/api/aviation/v1/list-airport-delays',
7478+
'/api/intelligence/v1/get-risk-scores',
7479+
'/api/conflict/v1/list-ucdp-events',
74797480
]);
74807481

74817482
const WIDGET_FETCH_TOOL = {
@@ -7484,7 +7485,7 @@ const WIDGET_FETCH_TOOL = {
74847485
input_schema: {
74857486
type: 'object',
74867487
properties: {
7487-
endpoint: { type: 'string', description: 'Approved API endpoint path (e.g. /rpc/worldmonitor.markets.v1.MarketsService/GetQuotes)' },
7488+
endpoint: { type: 'string', description: 'Approved API endpoint path (e.g. /api/market/v1/list-crypto-quotes)' },
74887489
params: { type: 'object', description: 'Query parameters as key-value string pairs', additionalProperties: { type: 'string' } },
74897490
},
74907491
required: ['endpoint'],
@@ -7493,20 +7494,27 @@ const WIDGET_FETCH_TOOL = {
74937494

74947495
const WIDGET_SYSTEM_PROMPT = `You are a WorldMonitor widget builder. Your job is to fetch live data and generate a display-only HTML widget using the WorldMonitor design system.
74957496
7496-
## Available data (use fetch_worldmonitor_data tool)
7497-
- /rpc/worldmonitor.markets.v1.MarketsService/GetQuotes — market quotes (stocks, indices)
7498-
- /rpc/worldmonitor.markets.v1.MarketsService/GetCommodities — commodity prices
7499-
- /rpc/worldmonitor.markets.v1.MarketsService/GetCryptoQuotes — crypto prices
7500-
- /rpc/worldmonitor.markets.v1.MarketsService/GetSectors — sector performance
7501-
- /rpc/worldmonitor.economic.v1.EconomicService/GetIndicators — economic indicators (GDP, inflation, etc.)
7502-
- /rpc/worldmonitor.trade.v1.TradeService/GetCustomsRevenue — US customs/tariff revenue by month
7503-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeRestrictions — WTO trade restrictions
7504-
- /rpc/worldmonitor.trade.v1.TradeService/GetTariffTrends — tariff rate history
7505-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeFlows — import/export flows
7506-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeBarriers — SPS/TBT barriers
7507-
- /rpc/worldmonitor.aviation.v1.AviationService/GetFlightDelays — international flight delays
7508-
- /rpc/worldmonitor.cii.v1.CiiService/GetCiiScores — country instability scores
7509-
- /rpc/worldmonitor.ucdp.v1.UcdpService/GetEvents — conflict events
7497+
## Available data tools
7498+
7499+
### fetch_worldmonitor_data — WorldMonitor structured data (preferred for these topics)
7500+
- /api/market/v1/list-market-quotes — market quotes (stocks, indices)
7501+
- /api/market/v1/list-commodity-quotes — commodity prices (oil, gold, silver, etc.)
7502+
- /api/market/v1/list-crypto-quotes — crypto prices
7503+
- /api/market/v1/get-sector-summary — sector performance
7504+
- /api/economic/v1/list-world-bank-indicators — economic indicators (GDP, inflation, unemployment, etc.)
7505+
- /api/economic/v1/get-macro-signals — macro signals (policy rates, yields, CPI trend)
7506+
- /api/trade/v1/get-customs-revenue — US customs/tariff revenue by month
7507+
- /api/trade/v1/get-trade-restrictions — WTO trade restrictions
7508+
- /api/trade/v1/get-tariff-trends — tariff rate history
7509+
- /api/trade/v1/get-trade-flows — import/export flows
7510+
- /api/trade/v1/get-trade-barriers — SPS/TBT barriers
7511+
- /api/aviation/v1/list-airport-delays — international flight delays by airport/region
7512+
- /api/intelligence/v1/get-risk-scores — country instability/risk scores
7513+
- /api/conflict/v1/list-ucdp-events — conflict events (UCDP data)
7514+
7515+
### search_web — Live internet search for ANY topic (use when topic not covered above)
7516+
Use search_web for: breaking news, weather, sports, elections, specific events, company news, scientific reports, geopolitical updates, sanctions, disasters, or any real-time topic.
7517+
Results include: title, url, snippet, publishedDate. Embed this data directly into the widget HTML.
75107518
75117519
## Design system CSS classes
75127520
Use ONLY these classes (no inline styles except var() references):
@@ -7536,16 +7544,92 @@ Use var(--widget-accent, var(--accent)) for themed highlights.
75367544
4. No interactive elements (no buttons, no tabs, no inputs).
75377545
5. Tables use class="trade-tariffs-table". Lists use class="trade-restrictions-list".
75387546
6. Always include a source footer: <div class="economic-footer"><span class="economic-source">Source: WorldMonitor</span></div>
7539-
7. If no data available: <div class="economic-empty">No data available</div>
7540-
8. The dashboard already provides the outer widget shell. Generate only the inner widget body markup.
7547+
7. If tool returns no data or an error: use <div class="economic-empty">No live data available</div> — NEVER write prose explanations.
7548+
8. If tool response contains "<!DOCTYPE" or "<html": it is an error — treat as no data and use the empty state HTML.
7549+
9. The dashboard already provides the outer widget shell. Generate only the inner widget body markup.
7550+
10. CRITICAL: Your response MUST always be HTML inside <!-- widget-html --> markers. NEVER respond with plain text, markdown, or explanations outside the HTML markers.
75417551
75427552
For modify requests: make targeted changes to improve the widget as requested.`;
75437553

7554+
const WIDGET_SEARCH_TOOL = {
7555+
name: 'search_web',
7556+
description: 'Search the web for current news, live data, or any topic not covered by WorldMonitor RPCs. Returns up to 8 results with title, URL, snippet, and publish date. Use this for topics like breaking news, weather, specific events, prices not in RPC catalog, etc.',
7557+
input_schema: {
7558+
type: 'object',
7559+
properties: {
7560+
query: { type: 'string', description: 'Search query — be specific for better results' },
7561+
},
7562+
required: ['query'],
7563+
},
7564+
};
7565+
75447566
const WIDGET_MAX_HTML = 50_000;
75457567
const WIDGET_PRO_MAX_HTML = 80_000;
75467568
const WIDGET_AGENT_KEY = (process.env.WIDGET_AGENT_KEY || '').trim();
75477569
const PRO_WIDGET_KEY = (process.env.PRO_WIDGET_KEY || '').trim();
75487570
const WIDGET_ANTHROPIC_KEY = (process.env.ANTHROPIC_API_KEY || '').trim();
7571+
const WIDGET_EXA_KEY = (process.env.EXA_API_KEYS || '').split(/[\n,]+/).map(k => k.trim()).filter(Boolean)[0] || '';
7572+
const WIDGET_BRAVE_KEY = (process.env.BRAVE_API_KEYS || '').split(/[\n,]+/).map(k => k.trim()).filter(Boolean)[0] || '';
7573+
7574+
async function performWidgetWebSearch(query) {
7575+
if (WIDGET_EXA_KEY) {
7576+
try {
7577+
const res = await fetch('https://api.exa.ai/search', {
7578+
method: 'POST',
7579+
headers: { 'Content-Type': 'application/json', 'x-api-key': WIDGET_EXA_KEY },
7580+
body: JSON.stringify({
7581+
query,
7582+
numResults: 8,
7583+
type: 'auto',
7584+
useAutoprompt: true,
7585+
contents: { text: { maxCharacters: 400 } },
7586+
}),
7587+
signal: AbortSignal.timeout(12_000),
7588+
});
7589+
if (res.ok) {
7590+
const payload = await res.json();
7591+
const results = (payload.results || []).map(r => ({
7592+
title: r.title || '',
7593+
url: r.url || '',
7594+
snippet: (r.text || '').slice(0, 400).trim(),
7595+
publishedDate: r.publishedDate || '',
7596+
})).filter(r => r.title && r.url);
7597+
if (results.length > 0) return { source: 'exa', results };
7598+
}
7599+
} catch (err) {
7600+
console.warn('[widget-search] Exa failed:', err.message);
7601+
}
7602+
}
7603+
7604+
if (WIDGET_BRAVE_KEY) {
7605+
try {
7606+
const url = new URL('https://api.search.brave.com/res/v1/web/search');
7607+
url.searchParams.set('q', query);
7608+
url.searchParams.set('count', '8');
7609+
url.searchParams.set('freshness', 'pw');
7610+
url.searchParams.set('search_lang', 'en');
7611+
url.searchParams.set('safesearch', 'moderate');
7612+
const res = await fetch(url.toString(), {
7613+
headers: { Accept: 'application/json', 'X-Subscription-Token': WIDGET_BRAVE_KEY },
7614+
signal: AbortSignal.timeout(12_000),
7615+
});
7616+
if (res.ok) {
7617+
const payload = await res.json();
7618+
const results = (payload.web?.results || []).map(r => ({
7619+
title: r.title || '',
7620+
url: r.url || '',
7621+
snippet: (r.description || '').slice(0, 400).trim(),
7622+
publishedDate: r.age || '',
7623+
})).filter(r => r.title && r.url);
7624+
if (results.length > 0) return { source: 'brave', results };
7625+
}
7626+
} catch (err) {
7627+
console.warn('[widget-search] Brave failed:', err.message);
7628+
}
7629+
}
7630+
7631+
return null;
7632+
}
75497633
const WIDGET_RATE_LIMIT = 10;
75507634
const PRO_WIDGET_RATE_LIMIT = 20;
75517635
const WIDGET_RATE_WINDOW_MS = 60 * 60 * 1000;
@@ -7744,7 +7828,7 @@ async function handleWidgetAgentRequest(req, res) {
77447828
model,
77457829
max_tokens: maxTokens,
77467830
system: systemPrompt,
7747-
tools: [WIDGET_FETCH_TOOL],
7831+
tools: [WIDGET_FETCH_TOOL, WIDGET_SEARCH_TOOL],
77487832
messages,
77497833
});
77507834

@@ -7764,7 +7848,25 @@ async function handleWidgetAgentRequest(req, res) {
77647848
if (response.stop_reason === 'tool_use') {
77657849
const toolResults = [];
77667850
for (const block of response.content) {
7767-
if (block.type !== 'tool_use' || block.name !== 'fetch_worldmonitor_data') continue;
7851+
if (block.type !== 'tool_use') continue;
7852+
7853+
if (block.name === 'search_web') {
7854+
const { query = '' } = block.input;
7855+
sendWidgetSSE(res, 'tool_call', { endpoint: `search:${String(query).slice(0, 80)}` });
7856+
try {
7857+
const searchResult = await performWidgetWebSearch(String(query));
7858+
if (searchResult) {
7859+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(searchResult.results).slice(0, 20_000) });
7860+
} else {
7861+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'No search results available. No search provider configured.' });
7862+
}
7863+
} catch (err) {
7864+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Search failed: ${err.message}` });
7865+
}
7866+
continue;
7867+
}
7868+
7869+
if (block.name !== 'fetch_worldmonitor_data') continue;
77687870
const { endpoint, params = {} } = block.input;
77697871
sendWidgetSSE(res, 'tool_call', { endpoint });
77707872

@@ -7783,7 +7885,12 @@ async function handleWidgetAgentRequest(req, res) {
77837885
signal: AbortSignal.timeout(15_000),
77847886
});
77857887
const data = await dataRes.text();
7786-
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: data.slice(0, 20_000) });
7888+
const trimmed = data.trimStart();
7889+
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
7890+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'Error: endpoint returned HTML instead of JSON. No data available.' });
7891+
} else {
7892+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: data.slice(0, 20_000) });
7893+
}
77877894
} catch (err) {
77887895
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Fetch failed: ${err.message}` });
77897896
}
@@ -7806,20 +7913,27 @@ async function handleWidgetAgentRequest(req, res) {
78067913

78077914
const WIDGET_PRO_SYSTEM_PROMPT = `You are a WorldMonitor PRO widget builder. Your job is to fetch live data and generate an interactive HTML widget body with inline JavaScript.
78087915
7809-
## Available data (use fetch_worldmonitor_data tool)
7810-
- /rpc/worldmonitor.markets.v1.MarketsService/GetQuotes — market quotes (stocks, indices)
7811-
- /rpc/worldmonitor.markets.v1.MarketsService/GetCommodities — commodity prices
7812-
- /rpc/worldmonitor.markets.v1.MarketsService/GetCryptoQuotes — crypto prices
7813-
- /rpc/worldmonitor.markets.v1.MarketsService/GetSectors — sector performance
7814-
- /rpc/worldmonitor.economic.v1.EconomicService/GetIndicators — economic indicators (GDP, inflation, etc.)
7815-
- /rpc/worldmonitor.trade.v1.TradeService/GetCustomsRevenue — US customs/tariff revenue by month
7816-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeRestrictions — WTO trade restrictions
7817-
- /rpc/worldmonitor.trade.v1.TradeService/GetTariffTrends — tariff rate history
7818-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeFlows — import/export flows
7819-
- /rpc/worldmonitor.trade.v1.TradeService/GetTradeBarriers — SPS/TBT barriers
7820-
- /rpc/worldmonitor.aviation.v1.AviationService/GetFlightDelays — international flight delays
7821-
- /rpc/worldmonitor.cii.v1.CiiService/GetCiiScores — country instability scores
7822-
- /rpc/worldmonitor.ucdp.v1.UcdpService/GetEvents — conflict events
7916+
## Available data tools
7917+
7918+
### fetch_worldmonitor_data — WorldMonitor structured data (preferred for these topics)
7919+
- /api/market/v1/list-market-quotes — market quotes (stocks, indices)
7920+
- /api/market/v1/list-commodity-quotes — commodity prices (oil, gold, silver, etc.)
7921+
- /api/market/v1/list-crypto-quotes — crypto prices
7922+
- /api/market/v1/get-sector-summary — sector performance
7923+
- /api/economic/v1/list-world-bank-indicators — economic indicators (GDP, inflation, unemployment, etc.)
7924+
- /api/economic/v1/get-macro-signals — macro signals (policy rates, yields, CPI trend)
7925+
- /api/trade/v1/get-customs-revenue — US customs/tariff revenue by month
7926+
- /api/trade/v1/get-trade-restrictions — WTO trade restrictions
7927+
- /api/trade/v1/get-tariff-trends — tariff rate history
7928+
- /api/trade/v1/get-trade-flows — import/export flows
7929+
- /api/trade/v1/get-trade-barriers — SPS/TBT barriers
7930+
- /api/aviation/v1/list-airport-delays — international flight delays by airport/region
7931+
- /api/intelligence/v1/get-risk-scores — country instability/risk scores
7932+
- /api/conflict/v1/list-ucdp-events — conflict events (UCDP data)
7933+
7934+
### search_web — Live internet search for ANY topic (use when topic not covered above)
7935+
Use search_web for: breaking news, weather, sports, elections, specific events, company news, scientific reports, geopolitical updates, sanctions, disasters, or any real-time topic.
7936+
Results include: title, url, snippet, publishedDate. Embed as const DATA = [...] in your inline script.
78237937
78247938
## Output: body content + inline scripts ONLY
78257939
Generate ONLY the <body> content — NO <!DOCTYPE>, NO <html>, NO <head> wrappers. The client provides the page skeleton with dark theme CSS and a strict CSP already in place.

server/worldmonitor/market/v1/stock-news-search.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CHROME_UA } from '../../../_shared/constants';
55
import { cachedFetchJson } from '../../../_shared/redis';
66
import { UPSTREAM_TIMEOUT_MS } from './_shared';
77

8-
export type StockNewsSearchProviderId = 'tavily' | 'brave' | 'serpapi' | 'google-news-rss';
8+
export type StockNewsSearchProviderId = 'exa' | 'brave' | 'serpapi' | 'google-news-rss';
99

1010
type StockNewsSearchResult = {
1111
provider: StockNewsSearchProviderId;
@@ -14,7 +14,7 @@ type StockNewsSearchResult = {
1414

1515
type SearchProviderDefinition = {
1616
id: Exclude<StockNewsSearchProviderId, 'google-news-rss'>;
17-
envKey: 'TAVILY_API_KEYS' | 'BRAVE_API_KEYS' | 'SERPAPI_API_KEYS';
17+
envKey: 'EXA_API_KEYS' | 'BRAVE_API_KEYS' | 'SERPAPI_API_KEYS';
1818
search: (query: string, maxResults: number, days: number, apiKey: string) => Promise<StockAnalysisHeadline[]>;
1919
};
2020

@@ -164,39 +164,38 @@ function recordProviderError(providerId: string, apiKey: string): void {
164164
state.errors.set(apiKey, (state.errors.get(apiKey) || 0) + 1);
165165
}
166166

167-
async function searchWithTavily(query: string, maxResults: number, days: number, apiKey: string): Promise<StockAnalysisHeadline[]> {
168-
const response = await fetch('https://api.tavily.com/search', {
167+
async function searchWithExa(query: string, maxResults: number, days: number, apiKey: string): Promise<StockAnalysisHeadline[]> {
168+
const startDate = new Date(Date.now() - days * 86_400_000).toISOString();
169+
const response = await fetch('https://api.exa.ai/search', {
169170
method: 'POST',
170171
headers: {
171172
'Content-Type': 'application/json',
173+
'x-api-key': apiKey,
172174
'User-Agent': CHROME_UA,
173175
},
174176
body: JSON.stringify({
175-
api_key: apiKey,
176177
query,
177-
topic: 'news',
178-
search_depth: 'advanced',
179-
max_results: Math.min(maxResults, 10),
180-
include_answer: false,
181-
include_raw_content: false,
182-
days,
178+
numResults: Math.min(maxResults, 10),
179+
type: 'neural',
180+
useAutoprompt: false,
181+
startPublishedDate: startDate,
183182
}),
184183
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
185184
});
186185

187186
if (!response.ok) {
188-
throw new Error(`Tavily HTTP ${response.status}`);
187+
throw new Error(`Exa HTTP ${response.status}`);
189188
}
190189

191190
const payload = await response.json() as {
192-
results?: Array<{ title?: string; url?: string; content?: string; published_date?: string; source?: string }>;
191+
results?: Array<{ title?: string; url?: string; publishedDate?: string; author?: string }>;
193192
};
194193
return dedupeHeadlines(
195194
(payload.results || []).map(item => ({
196195
title: String(item.title || '').trim(),
197-
source: String(item.source || '').trim() || extractDomain(String(item.url || '')),
196+
source: extractDomain(String(item.url || '')),
198197
link: String(item.url || '').trim(),
199-
publishedAt: parsePublishedAt(item.published_date),
198+
publishedAt: parsePublishedAt(item.publishedDate),
200199
})),
201200
maxResults,
202201
);
@@ -283,7 +282,7 @@ async function searchWithSerpApi(query: string, maxResults: number, days: number
283282

284283
async function searchViaProviders(query: string, maxResults: number, days: number): Promise<StockNewsSearchResult | null> {
285284
const providers: SearchProviderDefinition[] = [
286-
{ id: 'tavily', envKey: 'TAVILY_API_KEYS', search: searchWithTavily },
285+
{ id: 'exa', envKey: 'EXA_API_KEYS', search: searchWithExa },
287286
{ id: 'brave', envKey: 'BRAVE_API_KEYS', search: searchWithBrave },
288287
{ id: 'serpapi', envKey: 'SERPAPI_API_KEYS', search: searchWithSerpApi },
289288
];

src-tauri/sidecar/local-api-server.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ globalThis.fetch = async function ipv4Fetch(input, init) {
136136
};
137137

138138
const ALLOWED_ENV_KEYS = new Set([
139-
'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'TAVILY_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY',
139+
'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'EXA_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY',
140140
'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY',
141141
'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL',
142142
'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET',

0 commit comments

Comments
 (0)