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:
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod llm_commands;
|
||||
+18
-10
@@ -1,17 +1,25 @@
|
||||
mod llm;
|
||||
mod commands;
|
||||
|
||||
use llm::AppState;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
.plugin(tauri_plugin_log::Builder::default().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(AppState {
|
||||
config: Mutex::new(llm::LlmConfig::default()),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
commands::llm_commands::generate,
|
||||
commands::llm_commands::generate_stream,
|
||||
commands::llm_commands::get_llm_config,
|
||||
commands::llm_commands::set_llm_config,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
// ─── LLM Event (for streaming) ───────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
|
||||
pub enum LlmEvent {
|
||||
Token(String),
|
||||
Done(String), // full text
|
||||
Error(String),
|
||||
}
|
||||
|
||||
// ─── App State ────────────────────────────────────────────────
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<LlmConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmConfig {
|
||||
pub api_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
pub top_p: f32,
|
||||
}
|
||||
|
||||
impl Default for LlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI
|
||||
api_url: "http://localhost:11434".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "llama3.2".to_string(),
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
top_p: 0.9,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Generation Request ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GenerateRequest {
|
||||
pub prompt: String,
|
||||
pub system: Option<String>,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
// ─── OpenAI-Compatible Chat Response ─────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatChoice {
|
||||
pub message: ChatMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ─── Ollama Generate Response ─────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OllamaGenerateResponse {
|
||||
pub response: String,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OllamaChatResponse {
|
||||
pub message: ChatMessage,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
// ─── Helper: detect if we're talking to Ollama ───────────────
|
||||
|
||||
pub fn is_ollama(url: &str) -> bool {
|
||||
url.contains("localhost:11434") || url.contains("127.0.0.1:11434")
|
||||
}
|
||||
|
||||
// ─── Build system + user messages from a prompt ──────────────
|
||||
|
||||
pub fn build_messages(system: Option<&str>, prompt: &str) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
if let Some(sys) = system {
|
||||
messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: sys.to_string(),
|
||||
});
|
||||
}
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt.to_string(),
|
||||
});
|
||||
messages
|
||||
}
|
||||
Reference in New Issue
Block a user