diff --git a/Cargo.lock b/Cargo.lock index 0c4676c..827d556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,26 +872,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "doc_writing_tool" -version = "0.1.0" -dependencies = [ - "chrono", - "editor", - "eframe", - "egui", - "egui_commonmark", - "egui_extras", - "egui_file", - "image", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.12", - "uuid", - "walkdir", -] - [[package]] name = "document-features" version = "0.2.11" @@ -4940,6 +4920,26 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "worldcoder" +version = "0.1.0" +dependencies = [ + "chrono", + "editor", + "eframe", + "egui", + "egui_commonmark", + "egui_extras", + "egui_file", + "image", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.12", + "uuid", + "walkdir", +] + [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 37415ec..ef2e014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "doc_writing_tool" +name = "worldcoder" version = "0.1.0" edition = "2024" @@ -23,8 +23,3 @@ egui_commonmark = { version = "0.21.1", features = ["embedded_image"] } walkdir = "2.5.0" uuid = { version = "1.17.0", features = ["v4"] } reqwest = { version = "0.12.22", features = ["blocking", "json"] } - - -[target.x86_64-pc-windows-gnu] -linker = "x86_64-w64-mingw32-gcc" -ar = "x86_64-w64-mingw32-gcc-ar" diff --git a/src/editors/content_editor.rs b/src/editors/content_editor.rs index ad6929f..1bdd97f 100644 --- a/src/editors/content_editor.rs +++ b/src/editors/content_editor.rs @@ -6,14 +6,15 @@ use serde::{self, Deserialize, Serialize}; use crate::{ PROJECT_FOLDER, - editors::{context_editor::ProjectContext, tags::Tag}, - llm_integration::content_llm::ReadyState, + editors::{settings_editor::ProjectSettings, tags::Tag}, + llm_integration::content_llm::{ContentAI, ReadyState}, util, }; pub struct MainEditor { pub content: ContentSection, pub show_editor: bool, + pub editor_separate_window: bool, pub show_preview: bool, preview_cache: CommonMarkCache, dialog: Option, @@ -25,6 +26,7 @@ impl Clone for MainEditor { content: self.content.clone(), show_editor: self.show_editor, + editor_separate_window: self.editor_separate_window, show_preview: self.show_preview, preview_cache: CommonMarkCache::default(), dialog: self.dialog.clone(), @@ -57,158 +59,6 @@ pub struct ContentSection { pub saved: bool, } -#[derive(Clone)] -pub enum ContentAI { - Summarise { - open: bool, - content: String, - result: Arc>, - ready: Arc>, - }, - Continue { - open: bool, - content: String, - instruction: String, - max_tokens: usize, - context_override: String, - result: Arc>, - ready: Arc>, - }, -} - -impl ContentAI { - pub fn ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectContext) { - let mut is_open = *match self { - ContentAI::Summarise { open, .. } => open, - ContentAI::Continue { open, .. } => open, - }; - - if is_open { - egui::Window::new("AI Assistant") - .open(&mut is_open) - .show(ui.ctx(), |ui| match self { - ContentAI::Summarise { - content, - result, - ready, - .. - } => { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .max_width(ui.available_width()) - .show(ui, |ui| { - ui.add( - egui::TextEdit::multiline(content) - .frame(false) - .interactive(false), - ); - }); - ui.add( - egui::TextEdit::multiline(&mut *result.lock().unwrap()) - .font(egui::TextStyle::Monospace) - .interactive(false) - .frame(false) - .lock_focus(true) - .hint_text("Summary will appear here..."), - ); - if ui.button("Summarise").clicked() { - // Self::summarise(content, result.clone()); - *ready.lock().unwrap() = ReadyState::Generating; - } - } - ContentAI::Continue { - content, - instruction, - max_tokens, - context_override, - result, - ready, - .. - } => { - ui.weak("(The model will see current file content)"); - ui.separator(); - ui.add( - egui::TextEdit::multiline(instruction) - .frame(false) - .hint_text("Writing Instructions"), - ); - ui.separator(); - ui.add( - egui::TextEdit::multiline(context_override) - .frame(false) - .hint_text("Any additional context?"), - ); - ui.separator(); - ui.label("Max Tokens"); - ui.add(egui::Slider::new(max_tokens, 1000..=1000000)); - ui.separator(); - if ui.button("Continue").clicked() { - let context_override = context_override.clone(); - let content = content.clone(); - let instruction = instruction.clone(); - let max_tokens = *max_tokens; - let project = project.clone(); - let ai_context = project.ai_context_prompt.clone(); - let result = result.clone(); - let ready = ready.clone(); - - std::thread::spawn(move || { - *ready.lock().unwrap() = ReadyState::Generating; - - let result = crate::llm_integration::content_llm::continue_content( - if context_override.is_empty() { - ai_context - } else { - context_override - }, - content, - instruction, - max_tokens, - project, - result, - ); - if let Err(e) = result { - eprintln!("Error in content generation: {e}"); - } - - *ready.lock().unwrap() = ReadyState::Ready; - }); - } - - if *ready.lock().unwrap() == ReadyState::Generating { - ui.horizontal(|ui| { - ui.spinner(); - ui.label("Generating..."); - }); - - egui::ScrollArea::both() - .auto_shrink([false, false]) - .max_width(ui.available_width()) - .show(ui, |ui| { - ui.add( - egui::TextEdit::multiline(&mut *result.lock().unwrap()) - .font(egui::TextStyle::Monospace) - .interactive(false) - .frame(false) - .desired_width(ui.available_width()) - .lock_focus(true) - .hint_text("Content will appear here..."), - ); - }); - } else if *ready.lock().unwrap() == ReadyState::Idle { - ui.label("Idle"); - } - } - }); - } - - match self { - ContentAI::Summarise { open, .. } => *open = is_open, - ContentAI::Continue { open, .. } => *open = is_open, - }; - } -} - impl ContentSection { pub fn new() -> Self { Self { @@ -257,6 +107,7 @@ impl MainEditor { content: ContentSection::new(), show_editor: false, // Start with editor hidden show_preview: false, + editor_separate_window: false, preview_cache: CommonMarkCache::default(), dialog: None, } @@ -267,147 +118,167 @@ impl MainEditor { content, show_editor: true, show_preview: false, + editor_separate_window: false, preview_cache: CommonMarkCache::default(), dialog: None, } } - pub fn ui(&mut self, ctx: &egui::Context, project: &mut ProjectContext) { + pub fn render_ui(&mut self, project: &mut ProjectSettings, ui: &mut egui::Ui) { + if let Some(dialog) = &mut self.dialog { + dialog.ui(ui, project); + + match dialog { + ContentAI::Summarise { ready, result, .. } => { + if *ready.lock().unwrap() == ReadyState::Ready { + self.content.content.push_str(&result.lock().unwrap()); + self.content.saved = false; + *ready.lock().unwrap() = ReadyState::Idle; + } else if *ready.lock().unwrap() == ReadyState::Halted { + *ready.lock().unwrap() = ReadyState::Idle; + } + } + ContentAI::Continue { + ready, + result, + content, + .. + } => { + *content = self.content.content.clone(); + if *ready.lock().unwrap() == ReadyState::Ready { + self.content.content.push_str(&result.lock().unwrap()); + self.content.saved = false; + *ready.lock().unwrap() = ReadyState::Idle; + } else if *ready.lock().unwrap() == ReadyState::Halted { + *ready.lock().unwrap() = ReadyState::Idle; + } + } + } + } + + ui.vertical(|ui| { + // check for Ctrl+S to save + if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) { + if let Err(e) = self.content.save() { + eprintln!("Failed to save: {e}"); + } + } + + // display save state + util::saved_status( + ui, + self.content.saved, + &self.content.id, + &self.content.title, + ); + + // Save/Cancel buttons + ui.horizontal(|ui| { + // save button + if ui.button("Save").clicked() { + if let Err(e) = self.content.save() { + eprintln!("Failed to save: {e}"); + } + } + + // create copy button + if ui.button("Create Copy").clicked() { + let mut copy = self.clone(); + copy.content.id = uuid::Uuid::new_v4().to_string(); + copy.content.title = format!("{} (Copy)", self.content.title); + copy.content.save().unwrap(); + } + + // delete button + if ui.button("Delete").clicked() { + std::fs::remove_file( + PROJECT_FOLDER + .join("documents") + .join(format!("{}.json", self.content.id)), + ) + .unwrap(); + + *self = Self::new(); + } + + // revert changes button + if ui.button("Revert changes").clicked() { + self.content = ContentSection::load(&self.content.id).unwrap(); + } + + // preview toggle + ui.checkbox(&mut self.show_preview, "Preview"); + + // editor toggle + ui.checkbox(&mut self.editor_separate_window, "Pop out editor"); + }); + }); + + ui.separator(); + + // Name and description grid + egui::Grid::new("top_grid") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + ui.strong("Name"); + if ui + .add( + TextEdit::singleline(&mut self.content.title) + .desired_width(f32::INFINITY) + .frame(false), + ) + .changed() + { + self.content.saved = false; + } + ui.end_row(); + + ui.strong("Description"); + if ui + .add( + TextEdit::singleline(&mut self.content.description) + .desired_width(f32::INFINITY) + .frame(false), + ) + .changed() + { + self.content.saved = false; + } + ui.end_row(); + + ui.strong("Tags"); + Tag::selector_ui(&mut self.content.tags, ui, Some(&mut self.content.saved)); + ui.end_row(); + }); + + ui.separator(); + + if self.show_preview { + self.preview_ui(ui); + } + + self.editor_ui(ui, project); + } + + pub fn ui(&mut self, ctx: &egui::Context, project: &mut ProjectSettings) { // Show the editor window if enabled let mut show = self.show_editor; if show { - egui::Window::new("Markdown Editor") - .resizable(true) - .default_width(1000.0) - .default_height(800.0) - .open(&mut show) - .show(ctx, |ui| { - if let Some(dialog) = &mut self.dialog { - dialog.ui(ui, project); - - match dialog { - ContentAI::Summarise { ready, result, .. } => { - if *ready.lock().unwrap() == ReadyState::Ready { - self.content.content.push_str(&result.lock().unwrap()); - self.content.saved = false; - *ready.lock().unwrap() = ReadyState::Idle; - } - } - ContentAI::Continue { ready, result, .. } => { - if *ready.lock().unwrap() == ReadyState::Ready { - self.content.content.push_str(&result.lock().unwrap()); - self.content.saved = false; - *ready.lock().unwrap() = ReadyState::Idle; - } - } - } - } - - ui.vertical(|ui| { - // check for Ctrl+S to save - if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) { - if let Err(e) = self.content.save() { - eprintln!("Failed to save: {e}"); - } - } - - // display save state - util::saved_status( - ui, - self.content.saved, - &self.content.id, - &self.content.title, - ); - - // Save/Cancel buttons - ui.horizontal(|ui| { - // save button - if ui.button("Save").clicked() { - if let Err(e) = self.content.save() { - eprintln!("Failed to save: {e}"); - } - } - - // create copy button - if ui.button("Create Copy").clicked() { - let mut copy = self.clone(); - copy.content.id = uuid::Uuid::new_v4().to_string(); - copy.content.title = format!("{} (Copy)", self.content.title); - copy.content.save().unwrap(); - } - - // delete button - if ui.button("Delete").clicked() { - std::fs::remove_file( - PROJECT_FOLDER - .join("documents") - .join(format!("{}.json", self.content.id)), - ) - .unwrap(); - - *self = Self::new(); - } - - // revert changes button - if ui.button("Revert changes").clicked() { - self.content = ContentSection::load(&self.content.id).unwrap(); - } - - // preview toggle - ui.checkbox(&mut self.show_preview, "Preview"); - }); + if self.editor_separate_window { + egui::Window::new("Editor") + .resizable(true) + .default_width(1000.0) + .default_height(800.0) + .open(&mut show) + .show(ctx, |ui| { + self.render_ui(project, ui); }); - - ui.separator(); - - // Name and description grid - egui::Grid::new("top_grid") - .striped(true) - .num_columns(2) - .show(ui, |ui| { - ui.strong("Name"); - if ui - .add( - TextEdit::singleline(&mut self.content.title) - .desired_width(f32::INFINITY) - .frame(false), - ) - .changed() - { - self.content.saved = false; - } - ui.end_row(); - - ui.strong("Description"); - if ui - .add( - TextEdit::singleline(&mut self.content.description) - .desired_width(f32::INFINITY) - .frame(false), - ) - .changed() - { - self.content.saved = false; - } - ui.end_row(); - - ui.strong("Tags"); - Tag::selector_ui( - &mut self.content.tags, - ui, - Some(&mut self.content.saved), - ); - ui.end_row(); - }); - - ui.separator(); - - if self.show_preview { - self.preview_ui(ui); - } - - self.editor_ui(ui, project); + } else { + egui::CentralPanel::default().show(ctx, |ui| { + self.render_ui(project, ui); }); + } } self.show_editor = show; @@ -447,7 +318,7 @@ impl MainEditor { }); } - fn editor_ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectContext) { + fn editor_ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) { let _response = egui::ScrollArea::both() .auto_shrink([false, false]) .id_salt("editor_scroll") @@ -504,6 +375,8 @@ impl MainEditor { result: Arc::new(Mutex::new(String::new())), open: true, ready: Arc::new(Mutex::new(ReadyState::Idle)), + temperature: 0.7, + model_override: "".to_string(), }); } }); diff --git a/src/editors/context_editor.rs b/src/editors/context_editor.rs deleted file mode 100644 index a3cfc89..0000000 --- a/src/editors/context_editor.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::io::Read; - -use chrono::NaiveDate; -use egui::TextEdit; -use egui_extras::DatePickerButton; -use serde::{Deserialize, Serialize}; - -use crate::PROJECT_FOLDER; - -#[derive(Serialize, Deserialize, Clone)] -pub struct ProjectContext { - date: NaiveDate, - project_name: String, - project_author: String, - project_description: String, - - // settings - enable_ai: bool, - pub llm_api_uri: String, - pub llm_api_key: String, - pub ai_context_prompt: String, - - #[serde(skip)] - pub open: bool, -} - -impl ProjectContext { - #[allow(dead_code)] - pub fn new() -> Self { - Self::default() - } - - pub fn load() -> Self { - let path = PROJECT_FOLDER.join("context.json"); - if let Ok(mut file) = std::fs::File::open(path) { - let mut contents = String::new(); - file.read_to_string(&mut contents).unwrap(); - if let Ok(proj) = serde_json::from_str(&contents) { - return proj; - } - } - Self::default() - } - - pub fn save(&self) { - let path = PROJECT_FOLDER.join("context.json"); - let content = serde_json::to_string_pretty(self).unwrap(); - std::fs::write(path, content).unwrap(); - } - - #[allow(dead_code)] - pub fn ui(&mut self, ui: &mut egui::Ui) { - // table - egui::Grid::new("context_editor") - .striped(true) - .num_columns(2) - .show(ui, |ui| { - ui.label("Project Name"); - ui.text_edit_singleline(&mut self.project_name); - - ui.end_row(); - - ui.label("Project Author"); - ui.text_edit_singleline(&mut self.project_author); - - ui.end_row(); - - ui.label("Project Description"); - ui.text_edit_singleline(&mut self.project_description); - - ui.end_row(); - - ui.label("Date"); - ui.add(DatePickerButton::new(&mut self.date)); - - ui.end_row(); - - ui.label("Enable AI"); - ui.checkbox(&mut self.enable_ai, "Enable AI"); - - ui.end_row(); - - ui.label("LLM API URI"); - ui.text_edit_singleline(&mut self.llm_api_uri); - - ui.end_row(); - - ui.label("LLM API Key"); - ui.text_edit_singleline(&mut self.llm_api_key); - - ui.end_row(); - - ui.label("AI Context Prompt"); - ui.add(TextEdit::multiline(&mut self.ai_context_prompt) - .font(egui::TextStyle::Monospace) - .interactive(true) - .frame(false) - .lock_focus(true) - .hint_text("What is this project about? what should the LLM know when generating content for this project?")); - ui.end_row(); - }); - } - - pub fn ai_enabled(&mut self) -> bool { - let client = reqwest::blocking::Client::new(); - - if self.enable_ai { - return true; - } - - if client - .get(self.llm_api_uri.clone() + "/v1/models") - .send() - .is_ok() - { - self.enable_ai = true; - return true; - } - - false - } - - pub fn open(&mut self) { - self.open = true; - } - - pub fn close(&mut self) { - self.open = false; - } -} - -impl Default for ProjectContext { - fn default() -> Self { - Self { - date: chrono::Local::now().naive_local().into(), - project_name: "New Project".to_string(), - project_author: "Your Name".to_string(), - project_description: "Description of your project".to_string(), - - enable_ai: true, - llm_api_uri: "http://localhost:1234".to_string(), - llm_api_key: "".to_string(), - ai_context_prompt: "".to_string(), - - open: false, - } - } -} diff --git a/src/editors/mod.rs b/src/editors/mod.rs index c0d9b9f..72f44dc 100644 --- a/src/editors/mod.rs +++ b/src/editors/mod.rs @@ -1,7 +1,7 @@ pub mod asset_editor; pub mod content_editor; -pub mod context_editor; pub mod note_editor; pub mod object_editor; +pub mod settings_editor; pub mod tags; pub mod template_editor; diff --git a/src/editors/settings_editor.rs b/src/editors/settings_editor.rs new file mode 100644 index 0000000..c69e109 --- /dev/null +++ b/src/editors/settings_editor.rs @@ -0,0 +1,333 @@ +use std::{ + cell::OnceCell, + io::Read, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use chrono::NaiveDate; +use egui::TextEdit; +use egui_extras::DatePickerButton; +use serde::{Deserialize, Serialize}; + +use crate::{PROJECT_FOLDER, util::saved_status}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct ProjectSettings { + date: NaiveDate, + project_name: String, + project_author: String, + project_description: String, + + // AI settings + pub ai_context: String, + + // settings + #[serde(skip)] + pub global_settings: EditorSettings, + #[serde(skip)] + pub local_overrides: EditorSettings, + + #[serde(skip)] + pub open: bool, + + #[serde(skip)] + pub saved: bool, +} + +static GLOBAL_SETTINGS_PATH: LazyLock = + LazyLock::new(|| match std::env::var("XDG_CONFIG_HOME") { + Ok(path) => path + "/worldcoder/settings.json", + Err(_) => { + eprintln!( + "XDG_CONFIG_HOME not set, using default path of ~/.config/worldcoder/settings.json" + ); + "~/.config/worldcoder/settings.json".to_string() + } + }); + +impl ProjectSettings { + #[allow(dead_code)] + pub fn new() -> Self { + Self::default() + } + + pub fn load() -> Self { + let project_path = PROJECT_FOLDER.join("project.json"); + + let mut file = if let Ok(file) = std::fs::File::open(project_path) { + file + } else { + return Self::default(); + }; + + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + if let Ok(mut proj) = serde_json::from_str::(&contents) { + proj.saved = true; + + // load global settings + proj.global_settings = EditorSettings::load_global(); + + // load local overrides + proj.local_overrides = EditorSettings::load(); + + proj + } else { + Self::default() + } + } + + pub fn save(&mut self) { + let project_path = PROJECT_FOLDER.join("project.json"); + let content = serde_json::to_string_pretty(self).unwrap(); + std::fs::write(project_path, content).unwrap(); + + self.global_settings.save(); + self.local_overrides.save(); + + self.saved = true; + } + + #[allow(dead_code)] + pub fn ui(&mut self, ui: &mut egui::Ui) { + // save state + saved_status(ui, self.saved, "N/A", "Project Settings"); + if ui.button("Save").clicked() { + self.save(); + } + + ui.separator(); + + // project settings + ui.heading("Project Settings"); + egui::Grid::new("project settings") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + ui.label("Project Name"); + ui.text_edit_singleline(&mut self.project_name); + + ui.end_row(); + + ui.label("Project Author"); + ui.text_edit_singleline(&mut self.project_author); + + ui.end_row(); + + ui.label("Project Description"); + ui.text_edit_singleline(&mut self.project_description); + + ui.end_row(); + + ui.label("Date"); + ui.add(DatePickerButton::new(&mut self.date)); + + ui.end_row(); + + ui.label("AI Context Prompt"); + ui.add(TextEdit::multiline(&mut self.ai_context) + .font(egui::TextStyle::Monospace) + .interactive(true) + .frame(false) + .lock_focus(true) + .hint_text("What is this project about? what should the LLM know when generating content for this project?")); + + ui.end_row(); + }); + + ui.separator(); + + // local settings overrides for editor + ui.heading("Local Overrides"); + egui::Grid::new("local overrides") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + ui.label("Enable AI"); + if let Some(ai_enabled) = &mut self.local_overrides.ai_enabled { + ui.checkbox(ai_enabled, "Enable AI"); + if ui.button("Remove Override").clicked() { + self.local_overrides.ai_enabled = None; + } + } else if ui.button("Override").clicked() { + self.local_overrides.ai_enabled = Some(true); + } + + ui.end_row(); + + ui.label("LLM API URI"); + if let Some(llm_api_uri) = &mut self.local_overrides.llm_api_uri { + ui.text_edit_singleline(llm_api_uri); + if ui.button("Remove Override").clicked() { + self.local_overrides.llm_api_uri = None; + } + } else if ui.button("Override").clicked() { + self.local_overrides.llm_api_uri = Some("http://localhost:1234".to_string()); + } + + ui.end_row(); + + ui.label("LLM API Key"); + if let Some(llm_api_key) = &mut self.local_overrides.llm_api_key { + ui.text_edit_singleline(llm_api_key); + if ui.button("Remove Override").clicked() { + self.local_overrides.llm_api_key = None; + } + } else if ui.button("Override").clicked() { + self.local_overrides.llm_api_key = Some("1234".to_string()); + } + + ui.end_row(); + }); + + ui.separator(); + + // global editor settings + ui.heading("Global Editor Settings"); + egui::Grid::new("global settings") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + ui.label("Enable AI"); + ui.checkbox(&mut self.global_settings.ai_enabled.unwrap(), "Enable AI"); + + ui.end_row(); + + ui.label("LLM API URI"); + ui.text_edit_singleline(self.global_settings.llm_api_uri.as_mut().unwrap()); + + ui.end_row(); + + ui.label("LLM API Key"); + ui.text_edit_singleline(self.global_settings.llm_api_key.as_mut().unwrap()); + + ui.end_row(); + }); + } + + pub fn ai_enabled(&mut self) -> bool { + let client = reqwest::blocking::Client::new(); + + if self.global_settings.ai_enabled.unwrap() { + return true; + } + + if client + .get(self.global_settings.llm_api_uri.clone().unwrap() + "/v1/models") + .send() + .is_ok() + { + self.global_settings.ai_enabled = Some(true); + return true; + } + + false + } + + pub fn open(&mut self) { + self.open = true; + } + + pub fn close(&mut self) { + self.open = false; + } +} + +impl Default for ProjectSettings { + fn default() -> Self { + Self { + date: chrono::Local::now().naive_local().into(), + project_name: "New Project".to_string(), + project_author: "Your Name".to_string(), + project_description: "Description of your project".to_string(), + + ai_context: "".to_string(), + global_settings: EditorSettings::new(), + local_overrides: EditorSettings::new(), + + // window state + open: false, + saved: false, + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EditorSettings { + pub llm_api_uri: Option, + pub llm_api_key: Option, + pub ai_enabled: Option, + pub dark_theme: Option, + + #[serde(skip)] + is_global: bool, +} + +impl Default for EditorSettings { + fn default() -> Self { + Self { + llm_api_uri: Some("http://localhost:1234".to_string()), + llm_api_key: Some("".to_string()), + ai_enabled: Some(true), + dark_theme: Some(true), + + // window state + is_global: true, + } + } +} + +impl EditorSettings { + pub fn new() -> Self { + Self { + llm_api_uri: None, + llm_api_key: None, + ai_enabled: None, + dark_theme: None, + + is_global: false, + } + } + + pub fn load() -> Self { + let path = PROJECT_FOLDER.join("settings.json"); + let mut file = if let Ok(file) = std::fs::File::open(path) { + file + } else { + return Self::default(); + }; + + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + serde_json::from_str(&contents).unwrap() + } + + pub fn save(&self) { + let content = serde_json::to_string_pretty(self).unwrap(); + + let path = if self.is_global { + PathBuf::from(GLOBAL_SETTINGS_PATH.clone()) + } else { + PROJECT_FOLDER.join("settings.json") + }; + + std::fs::write(path, content).unwrap(); + } + + pub fn load_global() -> Self { + let path = PathBuf::from(GLOBAL_SETTINGS_PATH.clone()); + + if !path.exists() { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let content = serde_json::to_string_pretty(&Self::default()).unwrap(); + std::fs::write(&path, content).unwrap(); + + // return a default config + return Self::default(); + } + + let content = std::fs::read_to_string(path).unwrap(); + serde_json::from_str(&content).unwrap() + } +} diff --git a/src/llm_integration/content_llm.rs b/src/llm_integration/content_llm.rs index ddf02a7..2293a85 100644 --- a/src/llm_integration/content_llm.rs +++ b/src/llm_integration/content_llm.rs @@ -5,26 +5,287 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::editors::context_editor::ProjectContext; +use crate::editors::settings_editor::ProjectSettings; + +#[derive(Clone)] +pub enum ContentAI { + Summarise { + open: bool, + content: String, + result: Arc>, + ready: Arc>, + }, + Continue { + open: bool, + content: String, + instruction: String, + max_tokens: usize, + context_override: String, + result: Arc>, + ready: Arc>, + temperature: f32, + model_override: String, + }, +} + +impl ContentAI { + pub fn ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) { + let mut is_open = *match self { + ContentAI::Summarise { open, .. } => open, + ContentAI::Continue { open, .. } => open, + }; + + if is_open { + egui::Window::new("AI Assistant") + .open(&mut is_open) + .show(ui.ctx(), |ui| match self { + ContentAI::Summarise { .. } => { + Self::ui_summarise(self, ui, project); + } + ContentAI::Continue { .. } => { + Self::ui_continue(self, ui, project); + } + }); + } + + match self { + ContentAI::Summarise { open, .. } => *open = is_open, + ContentAI::Continue { open, .. } => *open = is_open, + }; + } + + fn ui_summarise(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) { + if let ContentAI::Summarise { + content, + result, + ready, + .. + } = self + { + egui::ScrollArea::vertical() + .id_salt("summarise") + .auto_shrink([false, false]) + .max_width(ui.available_width()) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(content) + .frame(false) + .interactive(false), + ); + }); + ui.add( + egui::TextEdit::multiline(&mut *result.lock().unwrap()) + .font(egui::TextStyle::Monospace) + .interactive(false) + .frame(false) + .lock_focus(true) + .hint_text("Summary will appear here..."), + ); + if ui.button("Summarise").clicked() { + // Self::summarise(content, result.clone()); + *ready.lock().unwrap() = ReadyState::Generating; + } + } + } + + fn ui_continue(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) { + if let ContentAI::Continue { + content, + instruction, + max_tokens, + context_override, + result, + ready, + temperature, + model_override, + .. + } = self + { + ui.weak("(The model will see current file content)"); + ui.separator(); + + // Instructions + egui::ScrollArea::both() + .id_salt("continue_instruction") + .auto_shrink([true, false]) + .max_height(ui.available_height() / 4.0) + .max_width(ui.available_width()) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(instruction) + .frame(false) + .desired_width(ui.available_width()) + .hint_text("Writing Instructions"), + ); + }); + ui.separator(); + + // Context + egui::ScrollArea::both() + .id_salt("continue_context") + .auto_shrink([true, false]) + .max_height(ui.available_height() / 4.0) + .max_width(ui.available_width()) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(context_override) + .frame(false) + .desired_width(ui.available_width()) + .hint_text("Any additional context?"), + ); + }); + ui.separator(); + + egui::Grid::new("continue_grid") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.label("Max Tokens"); + ui.add( + egui::DragValue::new(max_tokens) + .range(128..=u32::MAX) + .speed(128), + ); + ui.end_row(); + + ui.label("Temperature"); + ui.add( + egui::DragValue::new(temperature) + .range(0.0..=2.0) + .speed(0.1), + ); + ui.end_row(); + + ui.label("Model override"); + ui.add(egui::TextEdit::singleline(model_override)); + ui.end_row(); + }); + + ui.separator(); + + let mut ready_lock = ready.lock().unwrap(); + + match *ready_lock { + ReadyState::Idle => { + let continue_content = || { + let context_override = context_override.clone(); + let content = content.clone(); + let instruction = instruction.clone(); + let project = project.clone(); + let ai_context = project.ai_context.clone(); + let result = result.clone(); + let ready = ready.clone(); + + let options = AIOptions { + max_completion_tokens: *max_tokens, + temperature: *temperature, + model_override: if !model_override.is_empty() { + Some(model_override.clone()) + } else { + None + }, + }; + + result.lock().unwrap().clear(); + + std::thread::spawn(move || { + let result = crate::llm_integration::content_llm::continue_content( + ai_context + "\n" + &context_override, + content, + instruction, + options, + project, + result, + ready.clone(), + ); + if let Err(e) = result { + eprintln!("Error in content generation: {e}"); + } + }); + }; + + ui.horizontal(|ui| { + if ui.button("Generate ").clicked() { + continue_content(); + } + + ui.label("Idle"); + }); + } + + ReadyState::Generating => { + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + *ready_lock = ReadyState::Halted; + } + if ui.button("Stop").clicked() { + *ready_lock = ReadyState::Idle; + } + ui.spinner(); + ui.label("Generating..."); + }); + } + + ReadyState::Halted => {} + ReadyState::Ready => {} + } + + egui::ScrollArea::both() + .auto_shrink([true, true]) + .id_salt("llm_output") + .max_width(ui.available_width()) + .max_height(ui.available_height() / 4.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut *result.lock().unwrap()) + .font(egui::TextStyle::Monospace) + .interactive(false) + .desired_rows(0) + .frame(false) + .desired_width(ui.available_width()) + .lock_focus(true) + .hint_text("Content will appear here..."), + ); + }); + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Insert").clicked() { + *ready_lock = ReadyState::Ready; + } + + if ui.button("Clear").clicked() { + result.lock().unwrap().clear(); + } + }); + } + } +} pub fn continue_content( context: String, previous_content: String, instruction: String, - max_tokens: usize, - project: ProjectContext, + options: AIOptions, + project: ProjectSettings, result: Arc>, + ready: Arc>, ) -> Result<(), Box> { + println!("here"); + *ready.lock().unwrap() = ReadyState::Generating; + + println!("here2"); let client = reqwest::blocking::Client::new(); let messages = vec![ Message { role: "system".to_string(), content: " - Please generate content that is a direct continuation of the given text. + Please generate content that is a continuation of the given text. Your response should be a logical next step in the content and should not repeat any of the text from the instruction or the content. Do not generate any text that is not a direct continuation of the content. if extra instructions are provided, follow them exactly, otherwise continue the text in a logical way. + your output should NEVER be a repeat of any previous content ".to_string(), }, Message { @@ -33,33 +294,71 @@ pub fn continue_content( }, Message { role: "user".to_string(), - content: format!("Previous content: {previous_content}"), + content: format!("Content to continue: {previous_content}"), }, Message { role: "user".to_string(), content: format!("Specific instructions: {instruction}"), }, + ]; let request = ChatRequest { messages, - temperature: 0.7, - max_tokens, + temperature: options.temperature, + max_tokens: options.max_completion_tokens, + model: options.model_override, stream: true, }; - let response = client - .post(project.llm_api_uri.clone() + "/v1/chat/completions") - .json(&request) - .send()?; + let llm_api_uri = if let Some(uri) = project.local_overrides.llm_api_uri { + uri + } else { + project.global_settings.llm_api_uri.unwrap() + }; + + let api_key = if let Some(key) = project.local_overrides.llm_api_key { + if key.is_empty() { None } else { Some(key) } + } else if let Some(key) = project.global_settings.llm_api_key { + if key.is_empty() { None } else { Some(key) } + } else { + return Err("No API key found".into()); + }; + + let response = if let Some(k) = api_key { + client + .post(llm_api_uri + "/v1/chat/completions") + .json(&request) + .bearer_auth(k) + .send()? + } else { + client + .post(llm_api_uri + "/v1/chat/completions") + .json(&request) + .send()? + }; let reader = BufReader::new(response); - for line in reader.lines() { + // initial loop to check if the user has terminated the generation + { + let mut ready = ready.lock().unwrap(); + + if *ready == ReadyState::Halted { + result.lock().unwrap().clear(); + } + + if *ready != ReadyState::Generating { + *ready = ReadyState::Idle; + break; + } + } + let line = line?; if line == "data: [DONE]" { break; } + if let Some(json) = line.strip_prefix("data: ") { if let Ok(chunk) = serde_json::from_str::(json) { if let Some(content) = chunk.choices[0].delta.content.as_ref() { @@ -69,14 +368,23 @@ pub fn continue_content( } } + *ready.lock().unwrap() = ReadyState::Idle; + Ok(()) } +pub struct AIOptions { + pub max_completion_tokens: usize, + pub temperature: f32, + pub model_override: Option, +} + #[derive(Debug, PartialEq, Clone, Copy)] pub enum ReadyState { Idle, Generating, Ready, + Halted, } // Simple request structure @@ -86,6 +394,10 @@ struct ChatRequest { temperature: f32, max_tokens: usize, stream: bool, + + // if we give the API model:null it returns 500 + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, } // Streaming response structures diff --git a/src/main.rs b/src/main.rs index b2d67d4..fa25635 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,13 @@ use egui::ScrollArea; mod editors; mod explorer; mod llm_integration; -mod scene; mod util; use crate::{ editors::{ - asset_editor::Asset, content_editor, context_editor::ProjectContext, note_editor, - object_editor::ObjectInstance, tags::Tag, template_editor::Template, + asset_editor::Asset, content_editor, note_editor, object_editor::ObjectInstance, + settings_editor::ProjectSettings, tags::Tag, template_editor::Template, }, explorer::Explorer, }; @@ -31,16 +30,14 @@ fn main() { ..Default::default() }; - let _ = eframe::run_native("Code Editor", options, Box::new(|_cc| Ok(Box::new(app)))); + let _ = eframe::run_native("World Coder", options, Box::new(|_cc| Ok(Box::new(app)))); } pub struct Interface { - // dialog: Option, right_panel_content: RightPanelContent, editor: content_editor::MainEditor, - scene: scene::EditorScene, explorer: Explorer, - project: ProjectContext, + project: ProjectSettings, } impl eframe::App for Interface { @@ -84,9 +81,8 @@ impl Interface { Self { right_panel_content: RightPanelContent::None, editor: content_editor::MainEditor::new(), - scene: scene::EditorScene::new(), explorer: Explorer::new(), - project: ProjectContext::load(), + project: ProjectSettings::load(), } } @@ -125,8 +121,7 @@ impl Interface { .resizable(true) .default_width(250.0) .show(ctx, |ui| { - ui.heading("Project Files"); - ui.separator(); + ui.heading("Explorer"); let mut to_load: Option = None; let mut load_doc: Option = None; @@ -202,7 +197,6 @@ impl Interface { // render main content area fn render_main_content(&mut self, ctx: &egui::Context) { self.editor.ui(ctx, &mut self.project); - self.scene.ui(ctx, &mut self.explorer.objects()); } // configure appearance of UI elements diff --git a/src/scene.rs b/src/scene.rs index 5e289b8..e69de29 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -1,147 +0,0 @@ -use egui::{RichText, vec2}; - -use crate::{ - PROJECT_FOLDER, - editors::{ - object_editor::ObjectInstance, - template_editor::{FieldType, FieldValue, Template}, - }, -}; - -pub struct EditorScene { - rect: egui::Rect, -} - -impl EditorScene { - pub fn new() -> Self { - Self { - rect: egui::Rect::ZERO, - } - } - - pub fn ui(&mut self, ctx: &egui::Context, objects: &mut [ObjectInstance]) { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - egui::Scene::default() - .zoom_range(0.1..=10.0) - .show(ui, &mut self.rect, |ui| { - ui.horizontal_wrapped(|ui| { - ui.set_max_width(5000.0); - // Group objects by their template_id - use std::collections::HashMap; - let mut objects_by_template: HashMap> = - HashMap::new(); - - for obj in objects { - objects_by_template - .entry(obj.template_id.clone()) - .or_default() - .push(obj); - } - - // For each template with objects, create cards - for (template_id, template_objects) in objects_by_template { - // Try to load the template to get field definitions - if let Ok(mut template) = Template::load(&template_id) { - for obj in template_objects { - // Create a card for each object - egui::Frame::group(ui.style()) - .fill(egui::Color32::from_rgba_premultiplied( - 30, 30, 30, 200, - )) - .corner_radius(4.0) - .show(ui, |ui| { - - ui.vertical(|ui| { - ui.set_max_width(512.0); - ui.set_min_width(512.0); - - // Object name as header - ui.heading(RichText::new(&obj.name).strong()); - - // Show fields with on_preview = true - template.fields.sort_by_key(|field| field.field_type != FieldType::Image); - for field_def in &template.fields { - if field_def.on_preview { - if let Some(field_value) = - obj.fields.get(&field_def.name) - { - ui.separator(); - - match field_value { - FieldValue::SingleLine( - text, - ) => { - ui.strong(&field_def.name); - ui.label(text); - } - FieldValue::MultiLine( - text, - ) => { - ui.strong(&field_def.name); - ui.label(text); - } - FieldValue::Number(n) => { - ui.strong(&field_def.name); - ui.label(n.to_string()); - } - FieldValue::Date(date) => { - ui.strong(&field_def.name); - ui.label( - date.format( - "%Y-%m-%d", - ) - .to_string(), - ); - } - FieldValue::Image(value) => { - if !value.is_empty() { - let path = PROJECT_FOLDER.join("assets").join(value); - - if let Ok(bytes) = std::fs::read(&path) { - let image_source = egui::ImageSource::Bytes { - uri: std::borrow::Cow::Owned(path.to_str().unwrap().to_string()), - bytes: bytes.into(), - }; - ui.add( - egui::Image::new(image_source).fit_to_exact_size(vec2(512.0, 512.0)), - ); - } - } - } - FieldValue::Link( - target_id, - ) => { - ui.strong(&field_def.name); - ui.label(format!( - "→ {target_id}" - )); - } - FieldValue::Links( - links, - ) => { - ui.strong(&field_def.name); - ui.label(format!( - "{} links", - links.len() - )); - } - } - - } - } - } - }); - }); - - // Add some spacing between cards - ui.add_space(8.0); - } - } - } - }); - }); - }); - } -} diff --git a/~/.config/worldcoder/settings.json b/~/.config/worldcoder/settings.json new file mode 100644 index 0000000..a554f1b --- /dev/null +++ b/~/.config/worldcoder/settings.json @@ -0,0 +1,6 @@ +{ + "llm_api_uri": "http://localhost:1234", + "llm_api_key": "", + "ai_enabled": true, + "dark_theme": true +}