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:
@@ -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 `<proposal> — 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
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+15
@@ -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 --------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ mod config;
|
||||
mod gitsync;
|
||||
mod help;
|
||||
mod langtool;
|
||||
mod mistral;
|
||||
mod odt;
|
||||
mod order;
|
||||
mod preprocess;
|
||||
|
||||
+256
@@ -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<ChatMessage<'a>>,
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatMessage<'a> {
|
||||
role: &'a str,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatResponse {
|
||||
#[serde(default)]
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user