v0.2.0-llm: LLM integration, core utilities, and persistence

- Rust backend: LLM abstraction with OpenAI-compatible + Ollama API clients
- Tauri commands: generate, generate_stream, get/set LLM config
- LLM config: api_url, api_key, model, temperature, max_tokens, top_p
- Works with Ollama (localhost:11434), LM Studio, any OpenAI-compatible API
- Streaming infrastructure via Tauri Channels (LlmEvent)
- tauri-plugin-store, tauri-plugin-fs added
- Frontend: NPC Generator with race/class/alignment selection + AI generation
- Frontend: Dice Roller with notation parsing (2d6+3), presets, history
- Frontend: Session Logger with note-taking + AI summarization
- Frontend: Settings panel for LLM configuration (URL, key, model, temperature)
- App shell: left rail nav with active states, settings toggle
- All glassmorphism cards with gold glow hover states
- Builds clean: tsc, vite build, cargo check, tauri build
This commit is contained in:
itsamejms
2026-06-28 22:36:30 +01:00
parent f30a92ddeb
commit 9675e05b07
13 changed files with 1358 additions and 56 deletions
+166
View File
@@ -0,0 +1,166 @@
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
use serde_json::json;
use tauri::ipc::Channel;
// ─── Simple (non-streaming) generate ─────────────────────────
#[tauri::command]
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
let config = {
let guard = state.config.lock().map_err(|e| e.to_string())?;
guard.clone()
};
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
}
}
// ─── Streaming generate via Tauri Channel ─────────────────────
#[tauri::command]
pub async fn generate_stream(
state: tauri::State<'_, AppState>,
req: GenerateRequest,
channel: Channel<LlmEvent>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
// For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later
let result = if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
};
match result {
Ok(text) => {
let _ = channel.send(LlmEvent::Token(text.clone()));
let _ = channel.send(LlmEvent::Done(text));
}
Err(e) => {
let _ = channel.send(LlmEvent::Error(e));
}
}
});
Ok(())
}
// ─── Get / Set LLM Config ────────────────────────────────────
#[tauri::command]
pub fn get_llm_config(state: tauri::State<'_, AppState>) -> Result<crate::llm::LlmConfig, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
Ok(config.clone())
}
#[tauri::command]
pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::LlmConfig) -> Result<(), String> {
let mut lock = state.config.lock().map_err(|e| e.to_string())?;
*lock = config;
Ok(())
}
// ─── Ollama API ───────────────────────────────────────────────
async fn call_ollama(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
) -> Result<String, String> {
let body = json!({
"model": config.model,
"messages": messages,
"stream": false,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
}
});
let url = format!("{}/api/chat", config.api_url.trim_end_matches('/'));
let res = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("Ollama error {status}: {text}"));
}
let response: OllamaChatResponse = res
.json()
.await
.map_err(|e| format!("Ollama parse error: {e}"))?;
Ok(response.message.content)
}
// ─── OpenAI-compatible API ────────────────────────────────────
async fn call_openai(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
) -> Result<String, String> {
let body = json!({
"model": config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
});
let url = format!("{}/v1/chat/completions", config.api_url.trim_end_matches('/'));
let mut req = client.post(&url);
if !config.api_key.is_empty() {
req = req.bearer_auth(&config.api_key);
}
let res = req
.json(&body)
.send()
.await
.map_err(|e| format!("API request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("API error {status}: {text}"));
}
let response: ChatResponse = res
.json()
.await
.map_err(|e| format!("API parse error: {e}"))?;
response
.choices
.first()
.map(|c| c.message.content.clone())
.ok_or_else(|| "No response from API".to_string())
}
+1
View File
@@ -0,0 +1 @@
pub mod llm_commands;