diff --git a/README.md b/README.md index f354b98..8b6f589 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,31 @@ The bundled dictionaries live under `dictionaries/` and are derived from [SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept alongside them in each `license` file). +## Plot beats (Mistral) + +**Tools ▸ ✨ Generate plot beats (Mistral)…** turns a rough novel proposal into +a structured set of plot beats laid out in the classic **three-act structure**, +using the [Mistral](https://mistral.ai/) chat API. + +1. Open **Settings ▸ Mistral…** once and paste your **API key** (from + `console.mistral.ai`). You can also set the **Model** (default + `mistral-large-latest`) and the **Base URL** (default `https://api.mistral.ai`, + handy if you route through a proxy). The settings are remembered. +2. Choose **Tools ▸ Generate plot beats…** and pick a markdown (or text) file + describing the novel — its **characters** and a **loose plot summary**. The + request runs in the background, so the editor stays responsive. +3. The result opens in a **floating window** with the beats grouped under + *Act I — Setup*, *Act II — Confrontation*, and *Act III — Resolution*, each a + numbered list. You can **edit** the text in place, then: + * **💾 Save as new file** — writes ` — beats.md` into the workspace + (auto-numbered if that name is taken) and opens it in the editor, or + * **⧉ Copy** — copies the beats to the clipboard. + +The chosen proposal text is sent to Mistral to generate the beats; nothing else +in your workspace is transmitted. The API key is stored in the app's config file +in plain text. Because the request uses the pure-Rust `ureq`/rustls stack, the +binary stays self-contained. + ## Grammar & spelling (LanguageTool) The editor can *additionally* check the current file against a diff --git a/src/app.rs b/src/app.rs index 5f06452..6f9b505 100644 --- a/src/app.rs +++ b/src/app.rs @@ -235,6 +235,17 @@ pub struct App { /// Index (into the currently displayed matches) of the word a right-click /// suggestion menu is open for, if any. spell_menu: Option, + /// Whether the Mistral settings window is open. + show_mistral_settings: bool, + /// In-flight background plot-beat generation, if any. + beats_rx: Option>>, + /// One-line status for the plot-beat generator. + beats_status: String, + /// The generated (and user-editable) plot beats; `Some` opens the results + /// window. Holds an empty string while generating or on error. + beats_output: Option, + /// File stem of the proposal the beats came from, for the default save name. + beats_source_stem: Option, } impl App { @@ -293,6 +304,11 @@ impl App { spell_rx: None, spell_status: String::new(), spell_menu: None, + show_mistral_settings: false, + beats_rx: None, + beats_status: String::new(), + beats_output: None, + beats_source_stem: None, }; app.load_spell_dict(); app.open_workspace(); @@ -827,6 +843,259 @@ impl App { } } + /// Pick a novel-proposal markdown file and generate three-act plot beats + /// from it with the Mistral API, in the background. Opens the settings window + /// instead if no API key is configured yet. + fn generate_plot_beats(&mut self, ctx: &egui::Context) { + if self.beats_rx.is_some() { + return; // a generation is already running + } + if self.config.mistral_api_key.trim().is_empty() { + self.show_mistral_settings = true; + self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string(); + self.beats_output = Some(String::new()); + return; + } + + let mut dialog = rfd::FileDialog::new() + .set_title("Choose a novel proposal (markdown)") + .add_filter("Markdown / text", &["md", "markdown", "txt"]); + if let Some(dir) = self.workspace().to_str() { + dialog = dialog.set_directory(dir); + } + let Some(path) = dialog.pick_file() else { + return; // cancelled + }; + + let proposal = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + self.beats_status = format!("Could not read {}: {e}", path.display()); + self.beats_output = Some(String::new()); + return; + } + }; + if proposal.trim().is_empty() { + self.beats_status = "That file is empty — nothing to work from.".to_string(); + self.beats_output = Some(String::new()); + return; + } + self.beats_source_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string); + + let api_key = self.config.mistral_api_key.clone(); + let model = self.config.mistral_effective_model(); + let base = self.config.mistral_effective_base_url(); + let (tx, rx) = std::sync::mpsc::channel(); + self.beats_rx = Some(rx); + self.beats_status = "Generating plot beats…".to_string(); + self.beats_output = Some(String::new()); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal); + let _ = tx.send(result); + ctx.request_repaint(); + }); + } + + /// Pick up a finished plot-beat generation and show it (or its error). + fn poll_beats(&mut self) { + let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some(result) = received { + self.beats_rx = None; + match result { + Ok(beats) => { + self.beats_status = "Done — review, then save or copy.".to_string(); + self.beats_output = Some(beats); + } + Err(e) => { + self.beats_status = format!("✖ {e}"); + // Keep the window open so the error stays visible. + self.beats_output.get_or_insert_with(String::new); + } + } + } + } + + /// Write the generated beats to a new ` — beats.md` file in the + /// workspace (dodging name collisions), add it to the list, and open it. + fn save_beats_as_file(&mut self, beats: &str) { + let stem = self + .beats_source_stem + .clone() + .unwrap_or_else(|| "plot".to_string()); + let mut name = format!("{stem} — beats.md"); + let mut n = 2; + while self.path_for(&name).exists() { + name = format!("{stem} — beats-{n}.md"); + n += 1; + } + let path = self.path_for(&name); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim()); + match std::fs::write(&path, contents) { + Ok(_) => { + if !self.files.contains(&name) { + self.files.push(name.clone()); + self.persist_order(); + } + if let Some(idx) = self.files.iter().position(|f| f == &name) { + self.selected = None; // force the buffer to reload + self.select(idx); + } + self.status = format!("Saved {name}"); + self.beats_output = None; // close the window; the file is now open + self.beats_status.clear(); + } + Err(e) => self.beats_status = format!("Save failed: {e}"), + } + } + + /// Floating window presenting the generated plot beats, with save / copy. + fn beats_window(&mut self, ctx: &egui::Context) { + let mut open = true; + let mut close = false; + let mut save = false; + let running = self.beats_rx.is_some(); + // Edit a local copy so the button row can borrow `self` mutably; persist + // any edits back into `beats_output` afterwards. + let mut text = self.beats_output.clone().unwrap_or_default(); + egui::Window::new("✨ Plot beats (Mistral)") + .open(&mut open) + .resizable(true) + .default_width(560.0) + .default_height(520.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if running { + ui.spinner(); + } + if !self.beats_status.is_empty() { + ui.label(egui::RichText::new(&self.beats_status).weak()); + } + }); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut text) + .desired_width(f32::INFINITY) + .desired_rows(20) + .font(egui::TextStyle::Monospace), + ); + }); + ui.separator(); + ui.horizontal(|ui| { + let have = !text.trim().is_empty(); + if ui + .add_enabled(have, egui::Button::new("💾 Save as new file")) + .clicked() + { + save = true; + } + if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() { + ui.output_mut(|o| o.copied_text = text.clone()); + self.beats_status = "Copied to clipboard.".to_string(); + } + if ui.button("Close").clicked() { + close = true; + } + }); + }); + + // Persist edits made in the text box (unless we're about to close/save). + if self.beats_output.is_some() { + self.beats_output = Some(text.clone()); + } + if save { + self.save_beats_as_file(&text); + } else if close || !open { + self.beats_output = None; + self.beats_status.clear(); + } + } + + /// Floating window for editing the Mistral API connection settings. + fn mistral_settings_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_mistral_settings; + let mut close_clicked = false; + egui::Window::new("Mistral settings") + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + let mut save_now = false; + egui::Grid::new("mistral_settings_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("API key:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_api_key) + .password(true) + .hint_text("from console.mistral.ai") + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Model:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_model) + .hint_text(crate::mistral::DEFAULT_MODEL) + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Base URL:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_base_url) + .hint_text(crate::mistral::DEFAULT_BASE_URL) + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + }); + + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "Endpoint: {}/v1/chat/completions", + self.config.mistral_effective_base_url() + )) + .weak() + .monospace(), + ); + ui.label( + egui::RichText::new( + "The key is stored in this app's config file in plain text. \ + The proposal you choose is sent to Mistral to generate the beats.", + ) + .small() + .weak(), + ); + ui.separator(); + if ui.button("Close").clicked() { + close_clicked = true; + } + if save_now { + self.config.save(); + } + }); + + let now_open = open && !close_clicked; + if self.show_mistral_settings && !now_open { + self.config.save(); + } + self.show_mistral_settings = now_open; + } + /// Poll for a finished background check and store its results. fn poll_lt(&mut self) { let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok()); @@ -1564,6 +1833,19 @@ impl App { self.open_find(true); } }); + ui.menu_button("Tools", |ui| { + let busy = self.beats_rx.is_some(); + if ui + .add_enabled( + !busy, + egui::Button::new("✨ Generate plot beats (Mistral)…"), + ) + .clicked() + { + ui.close_menu(); + self.generate_plot_beats(ctx); + } + }); ui.menu_button("View", |ui| { if ui .checkbox(&mut self.config.show_preview, "Preview pane") @@ -1579,6 +1861,10 @@ impl App { ui.close_menu(); self.show_settings = true; } + if ui.button("Mistral…").clicked() { + ui.close_menu(); + self.show_mistral_settings = true; + } }); ui.menu_button("Help", |ui| { if ui.button("📝 Markdown cheatsheet").clicked() { @@ -2388,6 +2674,7 @@ impl eframe::App for App { self.poll_lt(); self.poll_settings_test(); self.poll_spell(); + self.poll_beats(); self.maybe_start_spell_check(ctx); self.menu_bar(ctx); @@ -2427,6 +2714,14 @@ impl eframe::App for App { self.settings_window(ctx); } + if self.show_mistral_settings { + self.mistral_settings_window(ctx); + } + + if self.beats_output.is_some() { + self.beats_window(ctx); + } + if self.show_cheatsheet { crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet); } diff --git a/src/config.rs b/src/config.rs index 8d58768..ab004bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,26 @@ pub struct Config { /// a [`crate::spell::DictEntry::id`]. #[serde(default = "default_spell_language")] pub spell_language: String, + /// Mistral API key for plot-beat generation. Stored in plain text; empty + /// disables the feature until set. + #[serde(default)] + pub mistral_api_key: String, + /// Mistral model id used for plot-beat generation (blank = the built-in default). + #[serde(default = "default_mistral_model")] + pub mistral_model: String, + /// Mistral API base URL (blank = the built-in default). Override for a proxy. + #[serde(default = "default_mistral_base_url")] + pub mistral_base_url: String, +} + +/// Default Mistral model. +pub fn default_mistral_model() -> String { + crate::mistral::DEFAULT_MODEL.to_string() +} + +/// Default Mistral API base URL. +pub fn default_mistral_base_url() -> String { + crate::mistral::DEFAULT_BASE_URL.to_string() } /// Live spell checking is on by default. @@ -109,6 +129,9 @@ impl Default for Config { languagetool_language: default_languagetool_language(), spell_check: default_spell_check(), spell_language: default_spell_language(), + mistral_api_key: String::new(), + mistral_model: default_mistral_model(), + mistral_base_url: default_mistral_base_url(), } } } @@ -142,6 +165,26 @@ impl Config { ) } + /// The Mistral model to use, falling back to the built-in default when blank. + pub fn mistral_effective_model(&self) -> String { + let m = self.mistral_model.trim(); + if m.is_empty() { + crate::mistral::DEFAULT_MODEL.to_string() + } else { + m.to_string() + } + } + + /// The Mistral base URL to use, falling back to the built-in default when blank. + pub fn mistral_effective_base_url(&self) -> String { + let b = self.mistral_base_url.trim().trim_end_matches('/'); + if b.is_empty() { + crate::mistral::DEFAULT_BASE_URL.to_string() + } else { + b.to_string() + } + } + pub fn load() -> Config { let path = config_path(); match std::fs::read_to_string(&path) { diff --git a/src/help.rs b/src/help.rs index f1477c8..5adb501 100644 --- a/src/help.rs +++ b/src/help.rs @@ -166,6 +166,21 @@ fn cheatsheet_body(ui: &mut egui::Ui) { (grammar in blue, spelling in red) take over until you edit again, then the \ offline spell check resumes.", ); + + section(ui, "Plot beats (Mistral)"); + body( + ui, + "Tools ▸ Generate plot beats… asks the Mistral API to turn a novel \ + proposal (characters + a loose plot summary) into three-act plot beats. \ + Pick the proposal file, and the beats open in a window to save as a new \ + workspace file or copy.", + ); + note( + ui, + "Set your API key first in Settings ▸ Mistral (the model and base URL are \ + configurable there too). The proposal text is sent to Mistral; the key is \ + stored in the app's config file in plain text.", + ); } // --- small rendering helpers -------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index d01e437..e5a94cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod config; mod gitsync; mod help; mod langtool; +mod mistral; mod odt; mod order; mod preprocess; diff --git a/src/mistral.rs b/src/mistral.rs new file mode 100644 index 0000000..14f24ce --- /dev/null +++ b/src/mistral.rs @@ -0,0 +1,256 @@ +//! Plot-beat generation via the Mistral chat-completions API. +//! +//! Given a novel *proposal* (its characters and a loose plot summary), this asks +//! a Mistral model to lay the story out as plot beats organised by the classic +//! three-act structure, returned as Markdown. +//! +//! The request is a plain `application/json` POST to `/v1/chat/completions` +//! (OpenAI-compatible), sent with `ureq` — which links a static rustls TLS +//! backend, so the binary stays self-contained (no C bindings). Both the model +//! and the base URL are caller-supplied so a proxy or a different model can be +//! used without code changes. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Default Mistral API base URL (the hosted service). +pub const DEFAULT_BASE_URL: &str = "https://api.mistral.ai"; + +/// Default model: a large model, well suited to long-form structured reasoning. +pub const DEFAULT_MODEL: &str = "mistral-large-latest"; + +/// Instruction given to the model. It fixes the output shape (three acts, one +/// numbered beat list per act) so the result drops straight into a manuscript. +const SYSTEM_PROMPT: &str = "\ +You are a developmental fiction editor who structures novels using the classic \ +three-act structure. You will be given a novel proposal — its characters and a \ +loose plot summary — and must lay the story out as a sequence of concrete plot \ +beats. + +Output requirements: +- Return GitHub-flavoured Markdown only. No preamble, commentary, or closing \ +remarks — begin with the first heading. +- Organise the beats under exactly three headings: `## Act I — Setup`, \ +`## Act II — Confrontation`, and `## Act III — Resolution`. +- Under each heading, give a numbered list of plot beats. Each beat is one or two \ +sentences describing what concretely happens, naming characters from the \ +proposal where relevant. +- Place the standard turning points in the right acts: the hook / ordinary world, \ +the inciting incident, and the first plot point (end of Act I); rising action, \ +the midpoint reversal, and the second plot point / low point (Act II); the \ +climax, the resolution, and a brief denouement (Act III). +- Stay faithful to the supplied premise, characters, and tone. Invent only what \ +is needed to connect the beats into a coherent, causal arc. +- Aim for roughly four to eight beats per act."; + +// ---- Wire format ----------------------------------------------------------- + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: Vec>, + temperature: f32, +} + +#[derive(Serialize)] +struct ChatMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Deserialize)] +struct ChatResponse { + #[serde(default)] + choices: Vec, +} + +#[derive(Deserialize)] +struct Choice { + message: RespMessage, +} + +#[derive(Deserialize)] +struct RespMessage { + #[serde(default)] + content: String, +} + +/// Generate three-act plot beats for `proposal` using the Mistral API. +/// +/// `api_key` is your Mistral API key (sent as a bearer token); `model` is the +/// model id (e.g. `mistral-large-latest`); `base_url` is the API root +/// (`https://api.mistral.ai`). Returns the model's Markdown beats, or a +/// human-readable error message. +pub fn generate_beats( + api_key: &str, + model: &str, + base_url: &str, + proposal: &str, +) -> Result { + let key = api_key.trim(); + if key.is_empty() { + return Err("No Mistral API key set (open Settings ▸ Mistral)".to_string()); + } + let base = base_url.trim().trim_end_matches('/'); + let base = if base.is_empty() { DEFAULT_BASE_URL } else { base }; + let model = { + let m = model.trim(); + if m.is_empty() { DEFAULT_MODEL } else { m } + }; + let url = format!("{base}/v1/chat/completions"); + + // Frame the proposal so the model reads it as source material, not as new + // instructions. + let user = format!( + "Here is the novel proposal (characters and a loose plot summary). \ + Produce the plot beats.\n\n---\n\n{}", + proposal.trim() + ); + let request = ChatRequest { + model, + messages: vec![ + ChatMessage { role: "system", content: SYSTEM_PROMPT }, + ChatMessage { role: "user", content: &user }, + ], + temperature: 0.6, + }; + let body = + serde_json::to_string(&request).map_err(|e| format!("Could not build request: {e}"))?; + + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(180)) + .build(); + + let resp = agent + .post(&url) + .set("Authorization", &format!("Bearer {key}")) + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send_string(&body) + .map_err(|e| friendly_error(base, e))?; + + let text = resp + .into_string() + .map_err(|e| format!("Could not read Mistral reply: {e}"))?; + parse_content(&text) +} + +/// Extract the assistant message content from a chat-completions JSON reply. +fn parse_content(body: &str) -> Result { + let parsed: ChatResponse = + serde_json::from_str(body).map_err(|e| format!("Unexpected reply from Mistral: {e}"))?; + let content = parsed + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .unwrap_or_default(); + let trimmed = content.trim(); + if trimmed.is_empty() { + Err("Mistral returned an empty response.".to_string()) + } else { + Ok(trimmed.to_string()) + } +} + +/// Turn a `ureq` error into a message, surfacing the API's own error text on an +/// HTTP status error where possible. +fn friendly_error(base: &str, err: ureq::Error) -> String { + match err { + ureq::Error::Status(code, resp) => { + let detail = resp + .into_string() + .ok() + .and_then(|b| extract_api_message(&b)) + .unwrap_or_default(); + let hint = match code { + 401 => " — check your API key", + 429 => " — rate limited or out of quota", + _ => "", + }; + if detail.is_empty() { + format!("Mistral API returned HTTP {code}{hint}") + } else { + format!("Mistral API error (HTTP {code}){hint}: {detail}") + } + } + ureq::Error::Transport(t) => { + format!("Could not reach Mistral at {base} ({t})") + } + } +} + +/// Pull a human-readable message out of a Mistral/OpenAI-style error body, which +/// may be `{"message": …}`, `{"error": {"message": …}}`, or `{"detail": …}`. +fn extract_api_message(body: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(body).ok()?; + let msg = v + .get("error") + .and_then(|e| e.get("message")) + .or_else(|| v.get("message")) + .or_else(|| v.get("detail")); + match msg { + Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_key_is_rejected_without_network() { + let err = generate_beats(" ", DEFAULT_MODEL, DEFAULT_BASE_URL, "a premise").unwrap_err(); + assert!(err.contains("API key"), "unexpected: {err}"); + } + + #[test] + fn parses_assistant_content() { + let body = r###"{ + "id": "cmpl-1", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "## Act I — Setup\n1. A beat."}, + "finish_reason": "stop" + } + ] + }"###; + let out = parse_content(body).unwrap(); + assert!(out.starts_with("## Act I — Setup")); + assert!(out.contains("1. A beat.")); + } + + #[test] + fn empty_choices_is_an_error() { + let err = parse_content(r#"{"choices": []}"#).unwrap_err(); + assert!(err.contains("empty"), "unexpected: {err}"); + } + + #[test] + fn blank_content_is_an_error() { + let body = r#"{"choices":[{"message":{"role":"assistant","content":" "}}]}"#; + assert!(parse_content(body).is_err()); + } + + #[test] + fn extracts_nested_error_message() { + let body = r#"{"error": {"message": "Invalid API key", "type": "auth"}}"#; + assert_eq!(extract_api_message(body).as_deref(), Some("Invalid API key")); + } + + #[test] + fn extracts_flat_and_detail_messages() { + assert_eq!( + extract_api_message(r#"{"message": "boom"}"#).as_deref(), + Some("boom") + ); + assert_eq!( + extract_api_message(r#"{"detail": "nope"}"#).as_deref(), + Some("nope") + ); + assert_eq!(extract_api_message(r#"{"unrelated": 1}"#), None); + } +}