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:
landon
2026-08-20 14:39:08 -05:00
parent 5bc2acad25
commit e5eadc08c1
6 changed files with 635 additions and 0 deletions
+43
View File
@@ -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) {