diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb50856..ade47a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub fn run() { news::save_rss_urls, // Geändert von save_rss_url zu save_rss_urls news::fetch_ai_news, news::fetch_rss_news, + news::save_groq_key, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/news.rs b/src-tauri/src/news.rs index 5358182..ff4646d 100644 --- a/src-tauri/src/news.rs +++ b/src-tauri/src/news.rs @@ -12,6 +12,7 @@ pub struct RssConfig { #[derive(Default)] pub struct NewsState { pub openrouter_key: Mutex, + pub groq_key: Mutex, pub rss_config: Mutex, } @@ -37,40 +38,38 @@ pub async fn save_openrouter_key(key: String, state: State<'_, NewsState>) -> Re Ok(()) } +#[tauri::command] +pub async fn save_groq_key(key: String, state: State<'_, NewsState>) -> Result<(), String> { + let mut lock = state.groq_key.lock().map_err(|_| "Lock failed")?; + *lock = key.trim().to_string(); + Ok(()) +} + #[tauri::command] pub async fn load_rss_config( app: AppHandle, state: State<'_, NewsState>, ) -> Result, String> { let path = get_config_path(&app); - - // Initialisierung mit Standard-Feeds, falls Datei nicht existiert if !path.exists() { let default_urls = vec![ "https://www.nasa.gov/news-release/feed/".to_string(), "https://www.heise.de/rss/heise-atom.xml".to_string(), ]; - let config = RssConfig { urls: default_urls.clone(), }; - if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let ron_str = ron::to_string(&config).map_err(|e| e.to_string())?; fs::write(&path, ron_str).map_err(|e| e.to_string())?; - let mut lock = state.rss_config.lock().unwrap(); lock.urls = default_urls.clone(); - return Ok(default_urls); } - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; let config: RssConfig = ron::from_str(&content).map_err(|e| e.to_string())?; - let mut lock = state.rss_config.lock().unwrap(); *lock = config.clone(); Ok(config.urls) @@ -84,40 +83,59 @@ pub async fn save_rss_urls( ) -> Result<(), String> { let config = RssConfig { urls }; let path = get_config_path(&app); - if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let ron_str = ron::to_string(&config).map_err(|e| e.to_string())?; fs::write(path, ron_str).map_err(|e| e.to_string())?; - let mut lock = state.rss_config.lock().unwrap(); *lock = config; Ok(()) } #[tauri::command] -pub async fn fetch_ai_news(state: State<'_, NewsState>) -> Result, String> { - let key = state - .openrouter_key - .lock() - .map_err(|_| "Lock failed")? - .clone(); +pub async fn fetch_ai_news( + provider: String, + state: State<'_, NewsState>, +) -> Result, String> { + let (key, url, model, author) = match provider.as_str() { + "groq" => { + let k = state.groq_key.lock().map_err(|_| "Lock failed")?.clone(); + ( + k, + "https://api.groq.com/openai/v1/chat/completions", + "llama-3.1-8b-instant", + "Groq AI", + ) + } + _ => { + let k = state + .openrouter_key + .lock() + .map_err(|_| "Lock failed")? + .clone(); + ( + k, + "https://openrouter.ai/api/v1/chat/completions", + "openai/gpt-oss-20b:free:online", + "OpenRouter AI", + ) + } + }; if key.is_empty() { - return Err("API Key fehlt.".into()); + return Err(format!("API Key für {} fehlt.", author)); } let client = reqwest::Client::new(); let response = client - .post("https://openrouter.ai/api/v1/chat/completions") + .post(url) .header("Authorization", format!("Bearer {}", key)) .json(&serde_json::json!({ - "model": "minimax/minimax-m2.1", + "model": model, "messages": [ - {"role": "system", "content": "Kurze News, 1-2 Sätze, Deutsch."}, - {"role": "user", "content": "Tech-News."} + {"role": "system", "content": "Kurze Tech-News, 1-2 Sätze, Deutsch. Nutze Markdown."}, + {"role": "user", "content": "Was gibt es neues in der Tech-Welt?"} ] })) .send() @@ -127,12 +145,12 @@ pub async fn fetch_ai_news(state: State<'_, NewsState>) -> Result) -> Result, } +#[derive(Serialize)] +struct AiArgs { + provider: String, +} #[function_component(News)] pub fn news() -> Html { @@ -40,12 +44,14 @@ pub fn news() -> Html { let error_msg = use_state(|| None::); let show_config = use_state(|| false); let current_mode = use_state(|| NewsMode::Ai); + let ai_provider = use_state(|| "openrouter".to_string()); let rss_urls = use_state(Vec::::new); - let api_key_ref = use_node_ref(); + let or_key_ref = use_node_ref(); + let groq_key_ref = use_node_ref(); let new_url_ref = use_node_ref(); - // Init: Konfiguration laden + // Init: RSS laden { let rss_urls = rss_urls.clone(); use_effect_with((), move |_| { @@ -60,23 +66,27 @@ pub fn news() -> Html { }); } - // News laden Funktion let load_news = { let articles = articles.clone(); let error_msg = error_msg.clone(); let mode = *current_mode; + let provider = (*ai_provider).clone(); Callback::from(move |_| { let articles = articles.clone(); let error_msg = error_msg.clone(); + let provider = provider.clone(); articles.set(None); error_msg.set(None); spawn_local(async move { - let cmd = if mode == NewsMode::Ai { - "fetch_ai_news" + let (cmd, args) = if mode == NewsMode::Ai { + ( + "fetch_ai_news", + serde_wasm_bindgen::to_value(&AiArgs { provider }).unwrap(), + ) } else { - "fetch_rss_news" + ("fetch_rss_news", JsValue::NULL) }; - match invoke(cmd, JsValue::NULL).await { + match invoke(cmd, args).await { Ok(res) => { let data = serde_wasm_bindgen::from_value::>(res) .unwrap_or_default(); @@ -88,22 +98,30 @@ pub fn news() -> Html { }) }; - // Automatisches Laden bei Modus-Wechsel + // Auto-Reload bei Switch { let load_news = load_news.clone(); - use_effect_with((*current_mode).clone(), move |_| { - load_news.emit(MouseEvent::new("click").unwrap()); - || () - }); + use_effect_with( + ((*current_mode).clone(), (*ai_provider).clone()), + move |_| { + load_news.emit(MouseEvent::new("click").unwrap()); + || () + }, + ); } let save_settings = { let rss_urls = rss_urls.clone(); - let api_key_ref = api_key_ref.clone(); + let or_ref = or_key_ref.clone(); + let groq_ref = groq_key_ref.clone(); let show_config = show_config.clone(); let load_news = load_news.clone(); Callback::from(move |_| { - let key = api_key_ref + let or_key = or_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); + let groq_key = groq_ref .cast::() .map(|i| i.value()) .unwrap_or_default(); @@ -113,7 +131,12 @@ pub fn news() -> Html { spawn_local(async move { let _ = invoke( "save_openrouter_key", - serde_wasm_bindgen::to_value(&StringArgs { key }).unwrap(), + serde_wasm_bindgen::to_value(&StringArgs { key: or_key }).unwrap(), + ) + .await; + let _ = invoke( + "save_groq_key", + serde_wasm_bindgen::to_value(&StringArgs { key: groq_key }).unwrap(), ) .await; let _ = invoke( @@ -131,20 +154,24 @@ pub fn news() -> Html {
-

{ if *current_mode == NewsMode::Ai { "📰 KI News" } else { "📻 RSS Feeds" } }

+

{ if *current_mode == NewsMode::Ai { "📰 KI News" } else { "📻 RSS" } }

- - + +
+ + if *current_mode == NewsMode::Ai { +
+ + +
+ } +
@@ -154,14 +181,17 @@ pub fn news() -> Html { if *show_config {
- - + + +
+
+ +
-
- +
-
- { for (*rss_urls).iter().enumerate().map(|(i, url)| html! { -
- {url} - -
- }) } -
@@ -202,11 +217,7 @@ pub fn news() -> Html { { if let Some(msg) = (*error_msg).clone() { html! {
{ format!("⚠️ Error: {}", msg) }
} } else if let Some(list) = (*articles).clone() { - if list.is_empty() { - html! {

{"Keine Nachrichten gefunden. Überprüfe deine Feeds."}

} - } else { - html! { for list.iter().map(|a| html! { }) } - } + html! { for list.iter().map(|a| html! { }) } } else { html! {
{"Lade Nachrichten..."}
} } } @@ -231,7 +242,6 @@ pub fn news_card(props: &NewsCardProps) -> Html { md_html::push_html(&mut out, parser); out }; - html! {
diff --git a/styles.css b/styles.css index 79ca18a..2bd6a7e 100644 --- a/styles.css +++ b/styles.css @@ -434,3 +434,88 @@ html { border-radius: 12px; text-align: center; } + +/* --- AI Provider Switch (Neu) --- */ +.provider-switch { + display: flex; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.08); + /* Zwischen h1 und den Icons positionieren */ + margin: 0 10px; +} + +.prov-btn { + border: none; + background: transparent; + color: var(--text-secondary); + padding: 6px 14px; + border-radius: 9px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 700; + transition: all 0.2s ease; +} + +.prov-btn.active { + background: var(--accent-color); + color: white; +} + +/* --- Config Section Refinement --- */ +.config-section { + margin-bottom: 20px; +} + +.config-section label { + display: block; + font-size: 0.7rem; + color: var(--text-secondary); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* --- Markdown Content Styling --- */ +.markdown-body { + font-size: 0.95rem; + color: var(--text-primary); + line-height: 1.6; +} + +.markdown-body h3 { + margin-top: 0; + margin-bottom: 10px; + font-size: 1.15rem; + color: #ffffff; + font-weight: 700; +} + +.markdown-body p { + margin: 0; +} + +/* --- Status Indicators --- */ +.loading-spinner { + text-align: center; + padding: 40px; + color: var(--text-secondary); + font-style: italic; + animation: pulse-subtle 2s infinite; +} + +.empty-msg { + text-align: center; + color: var(--text-secondary); + margin-top: 40px; + font-size: 0.9rem; + opacity: 0.8; +} + +/* --- Layout Adjustment für Provider Switch --- */ +.header-main { + display: flex; + align-items: center; + gap: 12px; +}