Skip to content

Commit 3a03514

Browse files
authored
Merge pull request #1 from notime2/feature/ai-query
feat: AI query support via OpenRouter
2 parents 3e4c6e2 + 217f6f8 commit 3a03514

5 files changed

Lines changed: 159 additions & 1 deletion

File tree

src/ai.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//! AI query module for RustCast.
2+
//!
3+
//! Sends user queries to a configurable AI provider (e.g. OpenRouter)
4+
//! and returns the response text.
5+
6+
use crate::config::AiConfig;
7+
use log::error;
8+
9+
/// Sends a query to the configured AI provider and returns the response.
10+
///
11+
/// This function is blocking and should be called from within a
12+
/// `tokio::task::spawn_blocking` context.
13+
pub fn query_ai(config: &AiConfig, query: &str) -> String {
14+
if config.api_key.is_empty() {
15+
return "Error: AI api_key is not set in config.toml".to_string();
16+
}
17+
18+
let body = serde_json::json!({
19+
"model": config.model,
20+
"messages": [
21+
{
22+
"role": "user",
23+
"content": query
24+
}
25+
]
26+
});
27+
28+
let response = minreq::post(&config.provider_url)
29+
.with_header("Authorization", format!("Bearer {}", config.api_key))
30+
.with_header("Content-Type", "application/json")
31+
.with_header("HTTP-Referer", "https://github.com/notime2/rustcast")
32+
.with_header("X-Title", "RustCast")
33+
.with_body(body.to_string())
34+
.with_timeout(30)
35+
.send();
36+
37+
match response {
38+
Ok(resp) => {
39+
let json: serde_json::Value =
40+
match serde_json::from_str(resp.as_str().unwrap_or("")) {
41+
Ok(v) => v,
42+
Err(e) => {
43+
error!("AI response parse error: {e}");
44+
return format!("Error parsing response: {e}");
45+
}
46+
};
47+
48+
json["choices"][0]["message"]["content"]
49+
.as_str()
50+
.unwrap_or("No response from AI")
51+
.to_string()
52+
}
53+
Err(e) => {
54+
error!("AI request error: {e}");
55+
format!("Error: {e}")
56+
}
57+
}
58+
}

src/app.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ pub enum Message {
8282
SwitchToPage(Page),
8383
ClipboardHistory(ClipBoardContentType),
8484
ChangeFocus(ArrowKey, u32),
85+
AiQuery(String),
86+
AiResponse(String),
8587
}
8688

8789
/// The window settings for rustcast

src/app/tile/update.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use rayon::iter::IntoParallelRefIterator;
1515
use rayon::iter::ParallelIterator;
1616
use rayon::slice::ParallelSliceMut;
1717

18+
use crate::ai;
1819
use crate::app::ToApp;
1920
use crate::app::ToApps;
2021
use crate::app::WINDOW_WIDTH;
@@ -26,9 +27,10 @@ use crate::app::menubar::menu_icon;
2627
use crate::app::tile::AppIndex;
2728
use crate::app::{Message, Page, tile::Tile};
2829
use crate::calculator::Expr;
30+
use crate::clipboard::ClipBoardContentType;
2931
use crate::commands::Function;
3032
use crate::commands::search_for_file;
31-
use crate::config::Config;
33+
use crate::config::{AiConfig, Config};
3234
use crate::unit_conversion;
3335
use crate::utils::is_valid_url;
3436
use crate::{app::ArrowKey, platform::focus_this_app};
@@ -431,6 +433,51 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
431433
Task::none()
432434
}
433435

436+
Message::AiQuery(query) => {
437+
info!("AI query: {query}");
438+
tile.results = vec![App {
439+
ranking: 0,
440+
open_command: AppCommand::Display,
441+
desc: "AI Query".to_string(),
442+
icons: None,
443+
display_name: "Thinking...".to_string(),
444+
search_name: String::new(),
445+
}];
446+
let ai_config = tile.config.ai.clone();
447+
Task::perform(
448+
async move {
449+
tokio::task::spawn_blocking(move || ai::query_ai(&ai_config, &query))
450+
.await
451+
.unwrap_or_else(|e| format!("Error: {e}"))
452+
},
453+
Message::AiResponse,
454+
)
455+
}
456+
457+
Message::AiResponse(response) => {
458+
info!("AI response received");
459+
tile.results = vec![App {
460+
ranking: 0,
461+
open_command: AppCommand::Function(Function::CopyToClipboard(
462+
ClipBoardContentType::Text(response.clone()),
463+
)),
464+
desc: "AI Response (click to copy)".to_string(),
465+
icons: None,
466+
display_name: response,
467+
search_name: String::new(),
468+
}];
469+
let len = tile.results.len();
470+
let max_elem = min(5, len);
471+
window::latest()
472+
.map(|x| x.unwrap())
473+
.map(move |id| {
474+
Message::ResizeWindow(
475+
id,
476+
((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32,
477+
)
478+
})
479+
}
480+
434481
Message::SearchQueryChanged(input, id) => {
435482
let mut task = Task::none();
436483
tile.focus_id = 0;
@@ -513,6 +560,24 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
513560
return Task::batch([zero_item_resize_task(id), task]);
514561
}
515562
}
563+
query if query.starts_with(&tile.config.ai.trigger)
564+
&& query.len() > tile.config.ai.trigger.len()
565+
&& query[tile.config.ai.trigger.len()..].starts_with(' ')
566+
&& tile.page == Page::Main =>
567+
{
568+
let ai_query = tile.query[tile.config.ai.trigger.len()..].trim().to_string();
569+
if !ai_query.is_empty() {
570+
tile.results = vec![App {
571+
ranking: 0,
572+
open_command: AppCommand::Message(Message::AiQuery(ai_query.clone())),
573+
desc: "AI Query".to_string(),
574+
icons: None,
575+
display_name: format!("Ask AI: {}", ai_query),
576+
search_name: String::new(),
577+
}];
578+
return single_item_resize_task(id);
579+
}
580+
}
516581
query => 'a: {
517582
if !query.starts_with(">") || tile.page != Page::Main {
518583
break 'a;

src/config.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub struct Config {
3030
pub aliases: HashMap<String, String>,
3131
pub search_dirs: Vec<String>,
3232
pub log_path: String,
33+
pub ai: AiConfig,
3334
}
3435

3536
impl Default for Config {
@@ -53,6 +54,37 @@ impl Default for Config {
5354
modes: HashMap::new(),
5455
aliases: HashMap::new(),
5556
shells: vec![],
57+
ai: AiConfig::default(),
58+
}
59+
}
60+
}
61+
62+
/// Configuration for the AI query feature.
63+
///
64+
/// Add this to your `config.toml`:
65+
/// ```toml
66+
/// [ai]
67+
/// provider_url = "https://openrouter.ai/api/v1/chat/completions"
68+
/// api_key = "sk-or-v1-your-key-here"
69+
/// model = "anthropic/claude-sonnet-4"
70+
/// trigger = "ai"
71+
/// ```
72+
#[derive(Debug, Clone, Deserialize, Serialize)]
73+
#[serde(default)]
74+
pub struct AiConfig {
75+
pub provider_url: String,
76+
pub api_key: String,
77+
pub model: String,
78+
pub trigger: String,
79+
}
80+
81+
impl Default for AiConfig {
82+
fn default() -> Self {
83+
Self {
84+
provider_url: "https://openrouter.ai/api/v1/chat/completions".to_string(),
85+
api_key: String::new(),
86+
model: "anthropic/claude-sonnet-4".to_string(),
87+
trigger: "ai".to_string(),
5688
}
5789
}
5890
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#![deny(clippy::dbg_macro)]
22

3+
mod ai;
34
mod app;
45
mod calculator;
56
mod clipboard;

0 commit comments

Comments
 (0)