perplexica-search-tui.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python3
  2. """
  3. 🔮 Perplexica CLI - Beautiful terminal interface for Perplexica API
  4. """
  5. import json
  6. import sys
  7. from typing import Optional, List, Tuple
  8. import httpx
  9. import click
  10. from rich.console import Console
  11. from rich.panel import Panel
  12. from rich.markdown import Markdown
  13. from rich.table import Table
  14. from rich.live import Live
  15. from rich.spinner import Spinner
  16. from rich.prompt import Prompt, Confirm
  17. from rich.syntax import Syntax
  18. from rich.text import Text
  19. from rich.columns import Columns
  20. import asyncio
  21. console = Console()
  22. # Configuration
  23. # NOTE: Set API_URL below to your own Perplexica API endpoint.
  24. API_URL = "http://localhost:3000/api/search" # <-- Set this to your own API endpoint
  25. console.print("This script is configured to use a private API endpoint. Make sure you've set API_URL to your own endpoint and are connected.")
  26. # Focus modes with emojis
  27. FOCUS_MODES = {
  28. "webSearch": {"emoji": "🌐", "name": "Web Search", "desc": "Search the entire web"},
  29. "academicSearch": {"emoji": "🎓", "name": "Academic Search", "desc": "Search academic papers and research"},
  30. "writingAssistant": {"emoji": "✍️", "name": "Writing Assistant", "desc": "Help with writing and editing"},
  31. "wolframAlphaSearch": {"emoji": "🧮", "name": "Wolfram Alpha", "desc": "Computational and mathematical queries"},
  32. "youtubeSearch": {"emoji": "📺", "name": "YouTube Search", "desc": "Search YouTube videos"},
  33. "redditSearch": {"emoji": "🤖", "name": "Reddit Search", "desc": "Search Reddit discussions (not currently working in Perplexica)"}
  34. }
  35. # Optimization modes
  36. OPTIMIZATION_MODES = {
  37. "speed": {"emoji": "⚡", "name": "Speed", "desc": "Fast responses"},
  38. "balanced": {"emoji": "⚖️", "name": "Balanced", "desc": "Balanced speed and quality"}
  39. }
  40. class PerplexicaCLI:
  41. def __init__(self):
  42. self.history: List[Tuple[str, str]] = []
  43. self.focus_mode = "webSearch"
  44. self.optimization_mode = "balanced"
  45. self.chat_model = {"provider": "openai", "name": "gpt-4o-mini"}
  46. self.embedding_model = {"provider": "openai", "name": "text-embedding-3-large"}
  47. def display_welcome(self):
  48. """Display welcome message with ASCII art"""
  49. welcome_text = """
  50. [bold cyan]╔═══════════════════════════════════════╗
  51. ║ 🔮 Perplexica Search CLI 🔮 ║
  52. ║ AI-Powered Search in your Terminal! ║
  53. ╚═══════════════════════════════════════╝[/bold cyan]
  54. """
  55. console.print(welcome_text)
  56. def display_focus_modes(self):
  57. """Display available focus modes in a beautiful table"""
  58. table = Table(title="🎯 Focus Modes", show_header=True, header_style="bold magenta")
  59. table.add_column("Mode", style="cyan", no_wrap=True)
  60. table.add_column("Icon", justify="center")
  61. table.add_column("Description", style="dim")
  62. for key, mode in FOCUS_MODES.items():
  63. table.add_row(key, mode["emoji"], f"{mode['name']} - {mode['desc']}")
  64. console.print(table)
  65. def get_mode_emoji(self) -> str:
  66. """Get emoji for current focus mode"""
  67. return FOCUS_MODES.get(self.focus_mode, {}).get("emoji", "🔍")
  68. def format_sources(self, sources: List[dict]) -> None:
  69. """Format and display sources"""
  70. if not sources:
  71. return
  72. console.print("\n📚 [bold yellow]Sources:[/bold yellow]")
  73. for i, source in enumerate(sources, 1):
  74. metadata = source.get("metadata", {})
  75. title = metadata.get("title", "Untitled")
  76. url = metadata.get("url", "")
  77. source_panel = Panel(
  78. f"[bold]{title}[/bold]\n[link={url}]{url}[/link]\n\n[dim]{source.get('pageContent', '')[:200]}...[/dim]",
  79. title=f"[{i}]",
  80. border_style="blue"
  81. )
  82. console.print(source_panel)
  83. async def stream_response(self, query: str, system_instructions: Optional[str] = None):
  84. """Stream response from API"""
  85. payload = {
  86. "chatModel": self.chat_model,
  87. "embeddingModel": self.embedding_model,
  88. "optimizationMode": self.optimization_mode,
  89. "focusMode": self.focus_mode,
  90. "query": query,
  91. "history": self.history,
  92. "stream": True
  93. }
  94. if system_instructions:
  95. payload["systemInstructions"] = system_instructions
  96. async with httpx.AsyncClient() as client:
  97. response_text = ""
  98. sources = []
  99. with Live(Spinner("dots", text="[cyan]Thinking...[/cyan]"), refresh_per_second=10) as live:
  100. async with client.stream('POST', API_URL, json=payload, timeout=60.0) as response:
  101. response.raise_for_status()
  102. async for line in response.aiter_lines():
  103. if line.strip():
  104. try:
  105. data = json.loads(line)
  106. if data["type"] == "sources":
  107. sources = data["data"]
  108. live.update("[green]✓ Sources found[/green]")
  109. elif data["type"] == "response":
  110. response_text += data["data"]
  111. # Update live display with markdown
  112. md = Markdown(response_text)
  113. panel = Panel(
  114. md,
  115. title=f"{self.get_mode_emoji()} Response",
  116. border_style="green",
  117. expand=False
  118. )
  119. live.update(panel)
  120. elif data["type"] == "done":
  121. break
  122. except json.JSONDecodeError:
  123. console.print(f"[red]Error parsing response: {line}[/red]")
  124. # Add to history
  125. self.history.append(["human", query])
  126. self.history.append(["assistant", response_text])
  127. # Display sources
  128. self.format_sources(sources)
  129. async def search(self, query: str, system_instructions: Optional[str] = None):
  130. """Perform a search query"""
  131. mode_info = FOCUS_MODES[self.focus_mode]
  132. console.print(f"\n{mode_info['emoji']} [bold cyan]Searching with {mode_info['name']}...[/bold cyan]")
  133. try:
  134. await self.stream_response(query, system_instructions)
  135. except httpx.HTTPError as e:
  136. console.print(f"[red]❌ HTTP Error: {e}[/red]")
  137. except Exception as e:
  138. console.print(f"[red]❌ Error: {e}[/red]")
  139. def interactive_mode(self):
  140. """Run in interactive mode"""
  141. self.display_welcome()
  142. while True:
  143. # Display current mode
  144. mode_info = FOCUS_MODES[self.focus_mode]
  145. opt_info = OPTIMIZATION_MODES[self.optimization_mode]
  146. status = Text()
  147. status.append("Mode: ", style="bold")
  148. status.append(f"{mode_info['emoji']} {mode_info['name']}", style="cyan")
  149. status.append(" | ", style="dim")
  150. status.append("Speed: ", style="bold")
  151. status.append(f"{opt_info['emoji']} {opt_info['name']}", style="yellow")
  152. console.print(Panel(status, expand=False))
  153. # Get user input
  154. console.print("\n[bold]Commands:[/bold] /mode, /speed, /clear, /help, /exit")
  155. query = Prompt.ask(f"\n{self.get_mode_emoji()} [bold]Ask anything[/bold]")
  156. if query.startswith("/"):
  157. self.handle_command(query)
  158. elif query.strip():
  159. # Check for system instructions
  160. instructions = None
  161. if "|" in query:
  162. query_parts = query.split("|", 1)
  163. query = query_parts[0].strip()
  164. instructions = query_parts[1].strip()
  165. console.print(f"[dim]Using instructions: {instructions}[/dim]")
  166. asyncio.run(self.search(query, instructions))
  167. def handle_command(self, command: str):
  168. """Handle special commands"""
  169. cmd = command.lower().strip()
  170. if cmd == "/mode":
  171. self.display_focus_modes()
  172. new_mode = Prompt.ask("Select focus mode", default=self.focus_mode)
  173. if new_mode in FOCUS_MODES:
  174. self.focus_mode = new_mode
  175. console.print(f"[green]✓ Switched to {FOCUS_MODES[new_mode]['name']}[/green]")
  176. else:
  177. console.print("[red]Invalid mode[/red]")
  178. elif cmd == "/speed":
  179. table = Table(title="⚡ Optimization Modes")
  180. table.add_column("Mode", style="cyan")
  181. table.add_column("Icon", justify="center")
  182. table.add_column("Description")
  183. for key, mode in OPTIMIZATION_MODES.items():
  184. table.add_row(key, mode["emoji"], mode["desc"])
  185. console.print(table)
  186. new_mode = Prompt.ask("Select optimization mode", default=self.optimization_mode)
  187. if new_mode in OPTIMIZATION_MODES:
  188. self.optimization_mode = new_mode
  189. console.print(f"[green]✓ Switched to {OPTIMIZATION_MODES[new_mode]['name']}[/green]")
  190. elif cmd == "/clear":
  191. self.history = []
  192. console.clear()
  193. self.display_welcome()
  194. console.print("[green]✓ History cleared[/green]")
  195. elif cmd == "/help":
  196. help_text = """
  197. [bold cyan]🔮 Perplexica CLI Help[/bold cyan]
  198. [bold]Commands:[/bold]
  199. /mode - Change search focus mode
  200. /speed - Change optimization mode
  201. /clear - Clear chat history and screen
  202. /help - Show this help message
  203. /exit - Exit the application
  204. [bold]Tips:[/bold]
  205. • Use | to add system instructions: "query | write in Spanish"
  206. • Press Ctrl+C to interrupt a search
  207. • History is maintained for context-aware responses
  208. """
  209. console.print(Panel(help_text, title="Help", border_style="blue"))
  210. elif cmd == "/exit":
  211. if Confirm.ask("Are you sure you want to exit?"):
  212. console.print("[yellow]👋 Goodbye![/yellow]")
  213. sys.exit(0)
  214. else:
  215. console.print(f"[red]Unknown command: {command}[/red]")
  216. @click.command()
  217. @click.option('--mode', '-m', type=click.Choice(list(FOCUS_MODES.keys())),
  218. help='Focus mode for search')
  219. @click.option('--query', '-q', help='Direct query (non-interactive mode)')
  220. @click.option('--speed', '-s', type=click.Choice(['speed', 'balanced']),
  221. default='balanced', help='Optimization mode')
  222. @click.option('--version', is_flag=True, help='Show version and exit')
  223. def main(mode: Optional[str], query: Optional[str], speed: str, version: bool):
  224. """
  225. 🔮 Perplexica CLI - Beautiful AI-powered search in your terminal
  226. """
  227. VERSION = "1.0.0"
  228. if version:
  229. console.print(f"[bold cyan]Perplexica CLI version {VERSION}[/bold cyan]")
  230. sys.exit(0)
  231. cli = PerplexicaCLI()
  232. cli.optimization_mode = speed
  233. if mode:
  234. cli.focus_mode = mode
  235. # Support reading query from stdin if --query is "-"
  236. if query == "-":
  237. query = sys.stdin.read().strip()
  238. if query:
  239. # Non-interactive mode
  240. asyncio.run(cli.search(query))
  241. else:
  242. # Interactive mode
  243. try:
  244. cli.interactive_mode()
  245. except KeyboardInterrupt:
  246. console.print("\n[yellow]👋 Goodbye![/yellow]")
  247. sys.exit(0)
  248. if __name__ == "__main__":
  249. # Check minimum Python version
  250. import platform
  251. if sys.version_info < (3, 8):
  252. sys.stderr.write("Perplexica CLI requires Python 3.8 or newer.\n")
  253. sys.exit(1)
  254. main()