Add Mistral plot-beat generation (Tools menu)
New Tools ▸ "Generate plot beats (Mistral)…" command: pick a novel proposal markdown file (characters + loose plot summary) and have the Mistral chat API lay it out as plot beats in the classic three-act structure. The result opens in a floating window where it can be edited, saved as a new "<proposal> — beats.md" workspace file, or copied. - src/mistral.rs: pure-Rust ureq/rustls POST to /v1/chat/completions (self-contained; no C bindings). System prompt fixes the three-act markdown shape; friendly API-error surfacing. Unit tests for content parsing, empty/blank responses, error-message extraction, and the missing-key guard. - config: mistral_api_key / mistral_model / mistral_base_url with effective-value helpers and defaults (mistral-large-latest, https://api.mistral.ai). - app: background generation + poll (editor stays responsive), a Settings ▸ Mistral… window (key/model/base URL), and the results window with save/copy. - Docs: README "Plot beats (Mistral)" section and help cheatsheet note. Verified: cargo build --release clean, 56 tests pass (+6 Mistral), ldd still only libc/libgcc/libm. Clippy: no new warnings in the added code (the two extra vs. the old 3-warning baseline are toolchain-surfaced in pre-existing app.rs code). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDfaEsTgcn61n3wgP62DFF
This commit is contained in:
+295
@@ -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<usize>,
|
||||
/// Whether the Mistral settings window is open.
|
||||
show_mistral_settings: bool,
|
||||
/// In-flight background plot-beat generation, if any.
|
||||
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||||
/// 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<String>,
|
||||
/// File stem of the proposal the beats came from, for the default save name.
|
||||
beats_source_stem: Option<String>,
|
||||
}
|
||||
|
||||
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 `<stem> — 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user