diff --git a/.env.example b/.env.example index dc463b9a..d002f86d 100755 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ HOST=0.0.0.0 # OPENAI_API_KEY=sk-... # OPENROUTER_API_KEY=sk-or-... # OPENROUTER_MODEL=anthropic/claude-sonnet-4 +# EXA_API_KEY= # Nano Claude Code (optional — Python agent; install: https://github.com/OpenLAIR/nano-claude-code ) # NANO_CLAUDE_CODE_COMMAND=nano-claude-code diff --git a/public/icons/news/exa.svg b/public/icons/news/exa.svg new file mode 100644 index 00000000..cee8b5bb --- /dev/null +++ b/public/icons/news/exa.svg @@ -0,0 +1,4 @@ + + + exa + diff --git a/server/routes/news.js b/server/routes/news.js index 1c4e23cb..cc43f2df 100644 --- a/server/routes/news.js +++ b/server/routes/news.js @@ -219,6 +219,31 @@ const SOURCE_REGISTRY = { }, requiresCredentials: false, }, + exa: { + label: 'Exa', + script: 'research-news/search_exa.py', + configFile: 'news-config-exa.json', + resultsFile: 'news-results-exa.json', + defaultConfig: { + research_domains: { + 'Large Language Models': { + keywords: ['large language model', 'LLM', 'transformer', 'foundation model'], + arxiv_categories: [], + priority: 5, + }, + 'AI Agents': { + keywords: ['AI agent', 'multi-agent', 'autonomous agent', 'tool use'], + arxiv_categories: [], + priority: 4, + }, + }, + top_n: 10, + queries: 'latest AI research,large language models,AI agents', + category: 'research paper', + days: 30, + }, + requiresCredentials: false, + }, }; async function ensureDataDir() { @@ -372,6 +397,11 @@ async function handleSearch(sourceName, req, res) { if (sourceName === 'xiaohongshu' && config.keywords) { args.push('--keywords', config.keywords); } + if (sourceName === 'exa') { + if (config.queries) args.push('--queries', config.queries); + if (config.category) args.push('--category', config.category); + if (config.days) args.push('--days', String(config.days)); + } // Build env — pass credentials if required. // Strip __PYVENV_LAUNCHER__ so uv-installed Python CLIs invoked by the diff --git a/server/scripts/research-news/search_exa.py b/server/scripts/research-news/search_exa.py new file mode 100644 index 00000000..b5a9ab90 --- /dev/null +++ b/server/scripts/research-news/search_exa.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +Exa AI-powered search script for the Research News feed. + +Uses the Exa REST API (https://api.exa.ai/search) to perform neural/semantic +web searches and score results against a research interest configuration. +Outputs filtered/ranked results in the same JSON format used by the other +search_*.py scripts. + +Requires EXA_API_KEY environment variable. +""" + +import json +import os +import sys +import logging +import ssl +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +try: + import certifi + CERTIFI_CA_BUNDLE = certifi.where() +except ImportError: + CERTIFI_CA_BUNDLE = None + +import urllib.request +import urllib.parse + +# --------------------------------------------------------------------------- +# Import shared scoring utilities +# --------------------------------------------------------------------------- +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from scoring_utils import ( + SCORE_MAX, + calculate_relevance_score, + calculate_recency_score, + calculate_quality_score, + calculate_recommendation_score, +) + +# --------------------------------------------------------------------------- +# Exa API configuration +# --------------------------------------------------------------------------- +EXA_API_URL = "https://api.exa.ai/search" +EXA_INTEGRATION_HEADER = "dr-claw" + +# Popularity: Exa score is 0-1 range; 0.8+ maps to max popularity +EXA_SCORE_FULL_POPULARITY = 0.8 + + +def build_ssl_context() -> ssl.SSLContext: + if CERTIFI_CA_BUNDLE and os.path.exists(CERTIFI_CA_BUNDLE): + return ssl.create_default_context(cafile=CERTIFI_CA_BUNDLE) + return ssl.create_default_context() + + +def exa_post_json( + url: str, + body: Dict, + headers: Optional[Dict[str, str]] = None, + timeout: int = 30, +) -> Dict: + """POST JSON to the Exa API and return the parsed response.""" + headers = headers or {} + data = json.dumps(body).encode("utf-8") + + if HAS_REQUESTS: + request_kwargs = { + "headers": headers, + "json": body, + "timeout": timeout, + } + if CERTIFI_CA_BUNDLE and os.path.exists(CERTIFI_CA_BUNDLE): + request_kwargs["verify"] = CERTIFI_CA_BUNDLE + response = requests.post(url, **request_kwargs) + response.raise_for_status() + return response.json() + + req = urllib.request.Request( + url, + data=data, + headers={**headers, "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout, context=build_ssl_context()) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def load_research_config(config_path: str) -> Dict: + """Load research interest configuration from a JSON or YAML file.""" + try: + with open(config_path, "r", encoding="utf-8-sig") as f: + if config_path.endswith(".json"): + config = json.load(f) + else: + try: + import yaml + config = yaml.safe_load(f) + except ImportError: + config = json.load(f) + return config + except Exception as e: + logger.error("Error loading config: %s", e) + return { + "research_domains": { + "LLM": { + "keywords": [ + "pre-training", "foundation model", "model architecture", + "large language model", "LLM", "transformer", + ], + "arxiv_categories": ["cs.AI", "cs.LG", "cs.CL"], + "priority": 5, + } + }, + "excluded_keywords": ["3D", "review", "workshop", "survey"], + } + + +def search_exa( + query: str, + api_key: str, + num_results: int = 30, + category: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + include_domains: Optional[List[str]] = None, + exclude_domains: Optional[List[str]] = None, + max_retries: int = 3, +) -> List[Dict]: + """ + Search using the Exa REST API. + + Args: + query: Search query string. + api_key: Exa API key. + num_results: Number of results to request. + category: Optional Exa category filter (e.g. "research paper", "news"). + start_date: ISO 8601 date string for start of date range. + end_date: ISO 8601 date string for end of date range. + include_domains: Restrict results to these domains. + exclude_domains: Exclude results from these domains. + max_retries: Maximum retry attempts. + + Returns: + List of result dicts from the Exa API. + """ + headers = { + "x-api-key": api_key, + "x-exa-integration": EXA_INTEGRATION_HEADER, + "Content-Type": "application/json", + "User-Agent": "ResearchNews-ExaFetcher/1.0", + } + + body: Dict = { + "query": query, + "type": "auto", + "numResults": num_results, + "contents": { + "text": {"maxCharacters": 1000}, + "highlights": {"maxCharacters": 300}, + "summary": {"query": query}, + }, + } + + if category: + body["category"] = category + if start_date: + body["startPublishedDate"] = start_date + if end_date: + body["endPublishedDate"] = end_date + if include_domains: + body["includeDomains"] = include_domains + if exclude_domains: + body["excludeDomains"] = exclude_domains + + logger.info("[Exa] Searching: '%s' (num_results=%d)", query, num_results) + if category: + logger.info("[Exa] Category filter: %s", category) + + for attempt in range(max_retries): + try: + data = exa_post_json(EXA_API_URL, body, headers=headers, timeout=30) + results = data.get("results", []) + logger.info("[Exa] Returned %d results", len(results)) + return results + except Exception as e: + error_msg = str(e) + logger.warning("[Exa] Error (attempt %d/%d): %s", attempt + 1, max_retries, e) + + is_rate_limit = "429" in error_msg or "Too Many Requests" in error_msg + if attempt < max_retries - 1: + import time + wait_time = 30 if is_rate_limit else (2 ** attempt) * 2 + logger.info("[Exa] Retrying in %d seconds...", wait_time) + time.sleep(wait_time) + else: + logger.error("[Exa] Failed after %d attempts", max_retries) + return [] + + return [] + + +def normalize_result(result: Dict) -> Optional[Dict]: + """ + Normalize an Exa search result into the internal paper dict format + used by the scoring functions. + + Args: + result: A single result from the Exa API response. + + Returns: + Normalized paper dict, or None if essential fields are missing. + """ + title = result.get("title") + url = result.get("url", "") + + if not title: + return None + + # Extract text content + text = result.get("text", "") + highlights = result.get("highlights", []) + summary_text = result.get("summary", "") + + # Use summary as abstract; fall back to highlights then truncated text + abstract = summary_text + if not abstract and highlights: + abstract = " ".join(highlights) + if not abstract and text: + abstract = text[:500] + + # Author + author = result.get("author", "") + + # Published date + published_at = result.get("publishedDate", "") + published_date = None + if published_at: + try: + # Exa returns YYYY-MM-DD or ISO 8601 + date_str = published_at[:10] # Take YYYY-MM-DD portion + published_date = datetime.strptime(date_str, "%Y-%m-%d") + except (ValueError, TypeError): + pass + + # Exa relevance score (0-1 range, higher is better) + exa_score = result.get("score", 0) or 0 + + return { + "title": title, + "url": url, + "summary": abstract, + "authors_str": author, + "published": published_at, + "published_date": published_date, + "exa_score": exa_score, + "highlights": highlights, + "categories": [], + "source": "exa", + } + + +def calculate_popularity_score(exa_score: float) -> float: + """ + Calculate popularity score from Exa's relevance score. + + Exa scores range from 0 to ~1. A score of 0.8+ maps to SCORE_MAX. + + Args: + exa_score: Exa relevance/match score. + + Returns: + Popularity score in [0, SCORE_MAX]. + """ + if exa_score <= 0: + return 0.0 + return min(exa_score / EXA_SCORE_FULL_POPULARITY * SCORE_MAX, SCORE_MAX) + + +def score_papers( + papers: List[Dict], + config: Optional[Dict] = None, +) -> Tuple[List[Dict], int]: + """ + Score papers, optionally filtering by research configuration. + + If config has research_domains, papers are filtered by relevance (unmatched + papers are excluded). If config is None or has no domains, all papers are + kept and scored by recency, popularity, and quality only. + + Args: + papers: Normalized paper dicts. + config: Research interest configuration (optional). + + Returns: + (scored_papers sorted by final_score descending, total_filtered count) + """ + domains = (config or {}).get("research_domains", {}) + excluded_keywords = (config or {}).get("excluded_keywords", []) + has_domains = bool(domains) + + scored: List[Dict] = [] + total_filtered = 0 + + for paper in papers: + if has_domains: + relevance, matched_domain, matched_keywords = calculate_relevance_score( + paper, domains, excluded_keywords + ) + if relevance == 0: + total_filtered += 1 + continue + else: + relevance = 1.0 + matched_domain = "exa_search" + matched_keywords = [] + + recency = calculate_recency_score(paper.get("published_date")) + popularity = calculate_popularity_score(paper.get("exa_score", 0)) + summary = paper.get("summary", "") + quality = calculate_quality_score(summary) + + final_score = calculate_recommendation_score( + relevance, recency, popularity, quality + ) + + scored.append({ + "id": paper.get("url", ""), + "title": paper["title"], + "authors": paper.get("authors_str", ""), + "abstract": paper.get("summary", ""), + "published": paper.get("published", ""), + "categories": paper.get("categories", []), + "relevance_score": round(relevance, 2), + "recency_score": round(recency, 2), + "popularity_score": round(popularity, 2), + "quality_score": round(quality, 2), + "final_score": final_score, + "matched_domain": matched_domain, + "matched_keywords": matched_keywords, + "link": paper.get("url", ""), + "source": "exa", + }) + + scored.sort(key=lambda x: x["final_score"], reverse=True) + return scored, total_filtered + + +def build_queries_from_config(config: Dict) -> List[str]: + """ + Build Exa search queries from research domain configuration. + + Each domain's keywords are combined into a single query string. + + Args: + config: Research interest configuration. + + Returns: + List of query strings (one per domain). + """ + domains = config.get("research_domains", {}) + if not domains: + return [] + + queries = [] + for domain_name, domain_config in domains.items(): + keywords = domain_config.get("keywords", []) + if keywords: + queries.append(" ".join(keywords)) + + return queries + + +def main(): + """Main entry point.""" + import argparse + + default_config = os.environ.get("OBSIDIAN_VAULT_PATH", "") + if default_config: + default_config = os.path.join( + default_config, "99_System", "Config", "research_interests.yaml" + ) + + parser = argparse.ArgumentParser( + description="Search the web using Exa AI and score results" + ) + parser.add_argument( + "--config", + type=str, + default=default_config or None, + help="Path to research interests config file", + ) + parser.add_argument( + "--output", + type=str, + default="exa_results.json", + help="Output JSON file path", + ) + parser.add_argument( + "--top-n", + type=int, + default=10, + help="Number of top results to return", + ) + parser.add_argument( + "--queries", + type=str, + default="", + help="Comma-separated search queries (overrides config-derived queries)", + ) + parser.add_argument( + "--category", + type=str, + default="", + help="Exa category filter (e.g. 'research paper', 'news', 'company')", + ) + parser.add_argument( + "--days", + type=int, + default=30, + help="Only return results published within the last N days", + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + stream=sys.stderr, + ) + + # Check for API key + api_key = os.environ.get("EXA_API_KEY", "") + if not api_key: + logger.error("EXA_API_KEY environment variable is not set") + empty_output = { + "top_papers": [], + "total_found": 0, + "total_filtered": 0, + "search_date": datetime.now().strftime("%Y-%m-%d"), + "error": "EXA_API_KEY not configured", + } + with open(args.output, "w", encoding="utf-8") as f: + json.dump(empty_output, f, ensure_ascii=False, indent=2) + print(json.dumps(empty_output, ensure_ascii=True, indent=2)) + return 1 + + # Load config + config = None + if args.config: + logger.info("Loading config from: %s", args.config) + config = load_research_config(args.config) + else: + logger.info("No config provided — using queries from CLI args") + + # Build query list + if args.queries: + queries = [q.strip() for q in args.queries.split(",") if q.strip()] + elif config: + queries = build_queries_from_config(config) + else: + queries = ["latest AI research papers"] + + if not queries: + queries = ["latest AI research papers"] + + logger.info("Search queries: %s", queries) + + # Date range + start_date = None + if args.days > 0: + start_date = (datetime.now() - timedelta(days=args.days)).strftime("%Y-%m-%dT00:00:00.000Z") + logger.info("Date filter: last %d days (since %s)", args.days, start_date) + + # Category + category = args.category if args.category else None + + # Search across all queries and collect results + all_results = [] + seen_urls = set() + + for query in queries: + results = search_exa( + query=query, + api_key=api_key, + num_results=30, + category=category, + start_date=start_date, + ) + + for r in results: + url = r.get("url", "") + if url and url not in seen_urls: + seen_urls.add(url) + all_results.append(r) + + logger.info("Total unique results across all queries: %d", len(all_results)) + + if not all_results: + logger.warning("No results returned from Exa") + output = { + "top_papers": [], + "total_found": 0, + "total_filtered": 0, + "search_date": datetime.now().strftime("%Y-%m-%d"), + } + with open(args.output, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + print(json.dumps(output, ensure_ascii=True, indent=2)) + return 0 + + # Normalize results + papers = [] + for result in all_results: + normalized = normalize_result(result) + if normalized: + papers.append(normalized) + + logger.info("Normalized %d papers from %d raw results", len(papers), len(all_results)) + + # Score (and optionally filter if config has domains) + scored_papers, total_filtered = score_papers(papers, config) + + logger.info( + "Scored %d papers (%d filtered out by relevance/exclusion)", + len(scored_papers), + total_filtered, + ) + + # Take top N + top_papers = scored_papers[: args.top_n] + + # Build output + output = { + "top_papers": top_papers, + "total_found": len(papers), + "total_filtered": total_filtered, + "search_date": datetime.now().strftime("%Y-%m-%d"), + } + + # Save to file + with open(args.output, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2, default=str) + + logger.info("Results saved to: %s", args.output) + logger.info("Top %d results:", len(top_papers)) + for i, p in enumerate(top_papers, 1): + logger.info( + " %d. %s... (Score: %s)", + i, + p["title"][:60], + p["final_score"], + ) + + # Also output to stdout + print(json.dumps(output, ensure_ascii=True, indent=2, default=str)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/components/news-dashboard/view/NewsDashboard.tsx b/src/components/news-dashboard/view/NewsDashboard.tsx index b603713f..30c27bd2 100644 --- a/src/components/news-dashboard/view/NewsDashboard.tsx +++ b/src/components/news-dashboard/view/NewsDashboard.tsx @@ -8,13 +8,14 @@ import UnifiedFeed from './UnifiedFeed'; import { useNewsDashboardData } from './useNewsDashboardData'; import type { NewsSourceKey } from './useNewsDashboardData'; -const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu']; +const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu', 'exa']; const SOURCE_LABEL_KEYS: Record = { arxiv: 'sources.arxiv', huggingface: 'sources.huggingface', x: 'sources.x', xiaohongshu: 'sources.xiaohongshuShort', + exa: 'sources.exa', }; const SOURCE_STAT_ACCENTS: Record = { @@ -22,6 +23,7 @@ const SOURCE_STAT_ACCENTS: Record = { huggingface: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/50 dark:text-yellow-300', x: 'bg-gray-200 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300', xiaohongshu: 'bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-300', + exa: 'bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300', }; function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { diff --git a/src/components/news-dashboard/view/SourceFilterBar.tsx b/src/components/news-dashboard/view/SourceFilterBar.tsx index d66b222c..c17c9ed0 100644 --- a/src/components/news-dashboard/view/SourceFilterBar.tsx +++ b/src/components/news-dashboard/view/SourceFilterBar.tsx @@ -13,6 +13,7 @@ const SOURCE_LABEL_KEYS: Record = { huggingface: 'sources.huggingface', x: 'sources.x', xiaohongshu: 'sources.xiaohongshu', + exa: 'sources.exa', }; const SOURCE_INACTIVE_COLORS: Record = { @@ -20,6 +21,7 @@ const SOURCE_INACTIVE_COLORS: Record = { huggingface: 'bg-transparent text-yellow-800/60 hover:bg-yellow-100/50 dark:text-yellow-400/60 dark:hover:bg-yellow-950/30', x: 'bg-transparent text-gray-600/60 hover:bg-gray-200/50 dark:text-gray-400/60 dark:hover:bg-gray-800/30', xiaohongshu: 'bg-transparent text-red-600/60 hover:bg-red-100/50 dark:text-red-400/60 dark:hover:bg-red-950/30', + exa: 'bg-transparent text-blue-700/60 hover:bg-blue-100/50 dark:text-blue-400/60 dark:hover:bg-blue-950/30', }; const SOURCE_ACTIVE_COLORS: Record = { @@ -27,9 +29,10 @@ const SOURCE_ACTIVE_COLORS: Record = { huggingface: 'bg-yellow-500 text-white shadow-md ring-2 ring-yellow-500/30 hover:bg-yellow-600 dark:bg-yellow-600 dark:ring-yellow-400/30 dark:hover:bg-yellow-500', x: 'bg-gray-800 text-white shadow-md ring-2 ring-gray-800/30 hover:bg-gray-900 dark:bg-gray-600 dark:ring-gray-500/30 dark:hover:bg-gray-500', xiaohongshu: 'bg-red-500 text-white shadow-md ring-2 ring-red-500/30 hover:bg-red-600 dark:bg-red-600 dark:ring-red-400/30 dark:hover:bg-red-500', + exa: 'bg-blue-600 text-white shadow-md ring-2 ring-blue-600/30 hover:bg-blue-700 dark:bg-blue-700 dark:ring-blue-500/30 dark:hover:bg-blue-600', }; -const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu']; +const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu', 'exa']; export default function SourceFilterBar({ activeSource, diff --git a/src/components/news-dashboard/view/SourceIcon.tsx b/src/components/news-dashboard/view/SourceIcon.tsx index d3caa18e..ab066907 100644 --- a/src/components/news-dashboard/view/SourceIcon.tsx +++ b/src/components/news-dashboard/view/SourceIcon.tsx @@ -5,6 +5,7 @@ const SOURCE_ICONS: Record = { huggingface: { light: '/icons/news/huggingface.svg' }, x: { light: '/icons/news/x-black.png', dark: '/icons/news/x-white.png' }, xiaohongshu: { light: '/icons/news/xiaohongshu.png' }, + exa: { light: '/icons/news/exa.svg' }, }; /** diff --git a/src/components/news-dashboard/view/SourceSettingsDialog.tsx b/src/components/news-dashboard/view/SourceSettingsDialog.tsx index 6720c3b8..41f906af 100644 --- a/src/components/news-dashboard/view/SourceSettingsDialog.tsx +++ b/src/components/news-dashboard/view/SourceSettingsDialog.tsx @@ -26,6 +26,7 @@ const SOURCE_TITLE_KEYS: Record = { huggingface: 'settings.huggingfaceTitle', x: 'settings.xTitle', xiaohongshu: 'settings.xiaohongshuTitle', + exa: 'settings.exaTitle', }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -503,6 +504,47 @@ export default function SourceSettingsDialog({ )} + {sourceKey === 'exa' && ( + <> +
+ + updateField('queries', e.target.value)} + placeholder={t('settings.exaSearchQueriesPlaceholder')} + className="mt-1.5 w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+
+ + +
+
+ + updateField('days', value)} + className="mt-1.5 w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm font-medium tabular-nums focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + )} + {/* Common fields */}
diff --git a/src/components/news-dashboard/view/UnifiedFeed.tsx b/src/components/news-dashboard/view/UnifiedFeed.tsx index 07088cbb..36bfc1f5 100644 --- a/src/components/news-dashboard/view/UnifiedFeed.tsx +++ b/src/components/news-dashboard/view/UnifiedFeed.tsx @@ -18,6 +18,7 @@ const SOURCE_LABEL_KEYS: Record = { huggingface: 'sources.huggingfaceFeed', x: 'sources.x', xiaohongshu: 'sources.xiaohongshu', + exa: 'sources.exa', }; const SOURCE_BORDER_COLORS: Record = { @@ -25,6 +26,7 @@ const SOURCE_BORDER_COLORS: Record = { huggingface: 'border-yellow-200/60 dark:border-yellow-800/40', x: 'border-gray-300/60 dark:border-gray-700/40', xiaohongshu: 'border-red-200/60 dark:border-red-800/40', + exa: 'border-blue-200/60 dark:border-blue-800/40', }; const SOURCE_HEADER_COLORS: Record = { @@ -32,6 +34,7 @@ const SOURCE_HEADER_COLORS: Record = { huggingface: 'text-yellow-700 dark:text-yellow-300', x: 'text-gray-700 dark:text-gray-300', xiaohongshu: 'text-red-600 dark:text-red-300', + exa: 'text-blue-700 dark:text-blue-300', }; const SOURCE_BADGE_COLORS: Record = { @@ -39,6 +42,7 @@ const SOURCE_BADGE_COLORS: Record = { huggingface: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-300', x: 'bg-gray-200 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300', xiaohongshu: 'bg-red-100 text-red-600 dark:bg-red-950/40 dark:text-red-300', + exa: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300', }; function SetupGuide({ sourceKey, onOpenSettings }: { sourceKey: NewsSourceKey; onOpenSettings: (key: NewsSourceKey) => void }) { diff --git a/src/components/news-dashboard/view/useNewsDashboardData.ts b/src/components/news-dashboard/view/useNewsDashboardData.ts index 9ac4ece3..0379a074 100644 --- a/src/components/news-dashboard/view/useNewsDashboardData.ts +++ b/src/components/news-dashboard/view/useNewsDashboardData.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '../../../utils/api'; import type { NewsItem } from './NewsItemCard'; -export type NewsSourceKey = 'arxiv' | 'huggingface' | 'x' | 'xiaohongshu'; +export type NewsSourceKey = 'arxiv' | 'huggingface' | 'x' | 'xiaohongshu' | 'exa'; export type SourceInfo = { key: NewsSourceKey; @@ -31,7 +31,7 @@ export type ResearchDomain = { // eslint-disable-next-line @typescript-eslint/no-explicit-any type SourceConfig = Record; -const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu']; +const ALL_SOURCES: NewsSourceKey[] = ['arxiv', 'huggingface', 'x', 'xiaohongshu', 'exa']; export function useNewsDashboardData() { const [sources, setSources] = useState([]); diff --git a/src/i18n/locales/en/news.json b/src/i18n/locales/en/news.json index 68bbb126..b116e56e 100644 --- a/src/i18n/locales/en/news.json +++ b/src/i18n/locales/en/news.json @@ -2,7 +2,7 @@ "hero": { "badge": "Research News", "title": "Research Feed", - "description": "Discover the latest from arXiv, HuggingFace, X, and Xiaohongshu — automatically scored by relevance, recency, popularity, and quality." + "description": "Discover the latest from arXiv, HuggingFace, X, Xiaohongshu, and Exa — automatically scored by relevance, recency, popularity, and quality." }, "sources": { "arxiv": "arXiv", @@ -10,7 +10,8 @@ "huggingfaceFeed": "HF Daily Papers", "x": "X", "xiaohongshu": "Xiaohongshu", - "xiaohongshuShort": "XHS" + "xiaohongshuShort": "XHS", + "exa": "Exa" }, "actions": { "settings": "Settings", @@ -80,6 +81,15 @@ "Click \"Start Search\" to fetch the latest posts" ], "note": "If you haven't installed xiaohongshu-cli yet, run: uv tool install xiaohongshu-cli" + }, + "exa": { + "steps": [ + "Set the EXA_API_KEY environment variable with your Exa API key", + "Click \"Settings\" to configure search queries, category, and date range", + "Optionally add research domains to filter results by your interests", + "Click \"Start Search\" to fetch the latest results from Exa" + ], + "note": "Get your API key at exa.ai" } }, "footer": { @@ -90,6 +100,11 @@ "huggingfaceTitle": "HuggingFace Settings", "xTitle": "X (Twitter) Settings", "xiaohongshuTitle": "Xiaohongshu Settings", + "exaTitle": "Exa Settings", + "exaSearchQueries": "Search Queries (comma-separated)", + "exaSearchQueriesPlaceholder": "latest AI research, large language models...", + "exaCategory": "Category", + "exaDays": "Results from last N days", "authentication": "Authentication", "xAuthDescription": "twitter-cli automatically extracts cookies from your browser (Chrome, Arc, Edge, Firefox). Make sure you are logged into X/Twitter in your browser. No API key needed.", "browserCookieAuth": "Browser Cookie Authentication", diff --git a/src/i18n/locales/ko/news.json b/src/i18n/locales/ko/news.json index 7a5e1dfe..8ce3e1d4 100644 --- a/src/i18n/locales/ko/news.json +++ b/src/i18n/locales/ko/news.json @@ -2,7 +2,7 @@ "hero": { "badge": "Research News", "title": "Research Feed", - "description": "Discover the latest from arXiv, HuggingFace, X, and Xiaohongshu — automatically scored by relevance, recency, popularity, and quality." + "description": "Discover the latest from arXiv, HuggingFace, X, Xiaohongshu, and Exa — automatically scored by relevance, recency, popularity, and quality." }, "sources": { "arxiv": "arXiv", @@ -10,7 +10,8 @@ "huggingfaceFeed": "HF Daily Papers", "x": "X", "xiaohongshu": "Xiaohongshu", - "xiaohongshuShort": "XHS" + "xiaohongshuShort": "XHS", + "exa": "Exa" }, "actions": { "settings": "Settings", @@ -80,6 +81,15 @@ "\"검색 시작\"을 눌러 최신 게시물을 가져오세요" ], "note": "If you haven't installed xiaohongshu-cli yet, run: uv tool install xiaohongshu-cli" + }, + "exa": { + "steps": [ + "EXA_API_KEY 환경 변수를 설정하세요", + "\"설정\"을 클릭하여 검색 쿼리, 카테고리, 날짜 범위를 구성하세요", + "관심 분야에 따라 결과를 필터링하려면 연구 분야를 추가하세요", + "\"검색 시작\"을 클릭하여 Exa에서 최신 결과를 가져오세요" + ], + "note": "exa.ai에서 API 키를 받으세요" } }, "footer": { @@ -90,6 +100,11 @@ "huggingfaceTitle": "HuggingFace 설정", "xTitle": "X (Twitter) 설정", "xiaohongshuTitle": "Xiaohongshu 설정", + "exaTitle": "Exa 설정", + "exaSearchQueries": "검색어 (쉼표로 구분)", + "exaSearchQueriesPlaceholder": "최신 AI 연구, 대규모 언어 모델...", + "exaCategory": "카테고리", + "exaDays": "최근 N일 결과", "authentication": "인증", "xAuthDescription": "twitter-cli는 브라우저(Chrome, Arc, Edge, Firefox)에서 쿠키를 자동으로 추출합니다. 브라우저에서 X/Twitter에 로그인되어 있어야 합니다. API 키는 필요하지 않습니다.", "browserCookieAuth": "브라우저 쿠키 인증", diff --git a/src/i18n/locales/zh-CN/news.json b/src/i18n/locales/zh-CN/news.json index dd82eaec..9f72415c 100644 --- a/src/i18n/locales/zh-CN/news.json +++ b/src/i18n/locales/zh-CN/news.json @@ -2,7 +2,7 @@ "hero": { "badge": "研究资讯", "title": "研究动态", - "description": "发现来自 arXiv、HuggingFace、X 和小红书的最新内容 — 自动按相关性、时效性、热度和质量评分。" + "description": "发现来自 arXiv、HuggingFace、X、小红书和 Exa 的最新内容 — 自动按相关性、时效性、热度和质量评分。" }, "sources": { "arxiv": "arXiv", @@ -10,7 +10,8 @@ "huggingfaceFeed": "HF 每日论文", "x": "X", "xiaohongshu": "小红书", - "xiaohongshuShort": "小红书" + "xiaohongshuShort": "小红书", + "exa": "Exa" }, "actions": { "settings": "设置", @@ -80,6 +81,15 @@ "点击「开始搜索」获取最新帖子" ], "note": "如尚未安装 xiaohongshu-cli,请运行:uv tool install xiaohongshu-cli" + }, + "exa": { + "steps": [ + "设置 EXA_API_KEY 环境变量", + "点击「设置」配置搜索查询、分类和日期范围", + "可选:添加研究领域以按兴趣筛选结果", + "点击「开始搜索」获取 Exa 最新结果" + ], + "note": "在 exa.ai 获取 API 密钥" } }, "footer": { @@ -90,6 +100,11 @@ "huggingfaceTitle": "HuggingFace 设置", "xTitle": "X (Twitter) 设置", "xiaohongshuTitle": "小红书设置", + "exaTitle": "Exa 设置", + "exaSearchQueries": "搜索查询(逗号分隔)", + "exaSearchQueriesPlaceholder": "最新 AI 研究, 大语言模型...", + "exaCategory": "分类", + "exaDays": "最近 N 天的结果", "authentication": "认证", "xAuthDescription": "twitter-cli 自动从浏览器提取 Cookie(Chrome、Arc、Edge、Firefox)。请确保已在浏览器中登录 X/Twitter。无需 API 密钥。", "browserCookieAuth": "浏览器 Cookie 认证",