Add thinking notice + fix Venice auto-recover caching (#193)

Opt-in 💭 thinking-notice for slow text generation, plus a fix making Venice unsupported-field auto-recovery survive the per-message controller rebuild. Prepares 1.24.0.

Co-authored-by: Aine <aine@etke.cc>
This commit is contained in:
Aine
2026-06-26 05:07:50 +01:00
committed by GitHub
parent 025accdeb0
commit 318a8fd91d
20 changed files with 618 additions and 35 deletions

View File

@@ -249,6 +249,10 @@ pub fn status_text_generation_entry_context_management(value: bool, set_where: &
format!("- ♻️ Context management: `{}` ({})\n", value, set_where)
}
pub fn status_text_generation_entry_thinking_notice(value: bool, set_where: &str) -> String {
format!("- 💭 Thinking notice: `{}` ({})\n", value, set_where)
}
pub fn status_text_generation_entry_sender_context(
value: impl std::fmt::Display,
set_where: &str,

View File

@@ -132,6 +132,18 @@ pub fn text_generation_context_management_intro() -> String {
)
}
pub fn text_generation_thinking_notice_heading() -> &'static str {
"💭 Thinking Notice"
}
pub fn text_generation_thinking_notice_intro() -> String {
format!(
"{}\n{}",
"Controls whether the bot posts a **\"thinking…\" notice** while text generation takes a while, so slow responses (for example, from reasoning models that run for minutes) don't look stuck.",
"When enabled, a placeholder message appears only after a short delay, updates periodically with varying status text, and is then edited in place to become the final answer. Disabled by default.",
)
}
pub fn text_generation_sender_context_heading() -> &'static str {
"👤 Sender Context Mode"
}

View File

@@ -11,6 +11,7 @@ pub mod provider;
pub mod room_config;
pub mod speech_to_text;
pub mod text_to_speech;
pub mod thinking;
pub mod usage;
pub const PROGRESS_INDICATOR_EMOJI: &str = "⏳";

134
src/strings/thinking.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::time::Duration;
/// After this much elapsed generation time, the notice escalates to the "medium" pool.
const TIER_MEDIUM_AFTER: Duration = Duration::from_secs(30);
/// After this much elapsed generation time, the notice escalates to the "deep" pool.
const TIER_DEEP_AFTER: Duration = Duration::from_secs(90);
/// Early flavor: the response is just taking a moment longer than instant.
const MESSAGES_LIGHT: &[&str] = &[
"*{{ baibot_name }} is thinking…*",
"*{{ baibot_name }} is mulling that over…*",
"*Hmm, let me think about that…*",
"*{{ baibot_name }} is gathering some thoughts…*",
"*One moment, {{ baibot_name }} is working on it…*",
"*{{ baibot_name }} is putting the pieces together…*",
"*Give {{ baibot_name }} a second here…*",
"*Let me think this one through…*",
"*{{ baibot_name }} is warming up the gears…*",
"*{{ baibot_name }} is on it…*",
];
/// Mid flavor: this is a real question and the model is genuinely working.
const MESSAGES_MEDIUM: &[&str] = &[
"*Huh, good one. {{ baibot_name }} is really thinking now…*",
"*Still working on it, {{ baibot_name }} wants to get this right…*",
"*This one needs a bit more thought…*",
"*{{ baibot_name }} is digging into this…*",
"*Hang tight, {{ baibot_name }} is turning it over…*",
"*Not a quick one, this. {{ baibot_name }} is still at it…*",
"*{{ baibot_name }} is chewing on this properly now…*",
"*This deserves some real thought, bear with {{ baibot_name }}…*",
"*{{ baibot_name }} is working through the details…*",
"*Won't be long, {{ baibot_name }} is closing in on it…*",
];
/// Deep flavor: a long-running generation (e.g. a reasoning model going for minutes).
const MESSAGES_DEEP: &[&str] = &[
"*Okay, this is a hard one. {{ baibot_name }} is really deep in thought…*",
"*{{ baibot_name }} is in the weeds on this one, thanks for your patience…*",
"*Still here, still thinking. {{ baibot_name }} doesn't want to rush it…*",
"*A proper puzzle, this. {{ baibot_name }} is taking the time to do it justice…*",
"*{{ baibot_name }} is really wrestling with this one…*",
"*A meaty question. {{ baibot_name }} is still turning it over…*",
"*{{ baibot_name }} hasn't forgotten you, just thinking hard…*",
"*Almost there, {{ baibot_name }} is pulling it all together…*",
"*{{ baibot_name }} is going the extra mile on this one…*",
"*Deep thoughts in progress. {{ baibot_name }} appreciates your patience…*",
];
/// Returns the flavor pool matching how long generation has been running.
pub fn messages_for_elapsed(elapsed: Duration) -> &'static [&'static str] {
if elapsed >= TIER_DEEP_AFTER {
MESSAGES_DEEP
} else if elapsed >= TIER_MEDIUM_AFTER {
MESSAGES_MEDIUM
} else {
MESSAGES_LIGHT
}
}
/// Picks one (still-untemplated) flavor message from the tier active at `elapsed`,
/// indexed by a monotonic `sequence` the caller increments once per notice.
///
/// Using a monotonic counter (rather than a clock-derived value) guarantees two
/// things the gesture-novelty goal needs: consecutive notices never repeat a line
/// (the index advances by one each tick, so it differs whenever the tier has more
/// than one message), and the pool is fully walked before any line recurs. The
/// caller seeds `sequence` with a per-generation value so different turns don't all
/// open on the same line. A clock-derived index can't promise this: `interval_at`
/// ticks on a near-fixed schedule, so its sub-second component clusters and would
/// re-pick the same line.
pub fn pick_message(elapsed: Duration, sequence: usize) -> &'static str {
let pool = messages_for_elapsed(elapsed);
pool[sequence % pool.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_for_elapsed_returns_the_right_tier() {
// Boundaries: light below 30s, medium [30s, 90s), deep at/after 90s.
// The pools have distinct content, so value comparison identifies the tier.
assert_eq!(messages_for_elapsed(Duration::from_secs(0)), MESSAGES_LIGHT);
assert_eq!(messages_for_elapsed(Duration::from_secs(29)), MESSAGES_LIGHT);
assert_eq!(messages_for_elapsed(Duration::from_secs(30)), MESSAGES_MEDIUM);
assert_eq!(messages_for_elapsed(Duration::from_secs(89)), MESSAGES_MEDIUM);
assert_eq!(messages_for_elapsed(Duration::from_secs(90)), MESSAGES_DEEP);
assert_eq!(messages_for_elapsed(Duration::from_secs(600)), MESSAGES_DEEP);
}
#[test]
fn every_tier_is_non_empty() {
for pool in [MESSAGES_LIGHT, MESSAGES_MEDIUM, MESSAGES_DEEP] {
assert!(!pool.is_empty());
for message in pool {
assert!(!message.trim().is_empty());
}
}
}
#[test]
fn every_tier_uses_the_bot_name_template() {
// Not every line names the bot (some are first-person flavor), but each
// tier exercises the template var so substitution is wired through.
for pool in [MESSAGES_LIGHT, MESSAGES_MEDIUM, MESSAGES_DEEP] {
assert!(pool.iter().any(|m| m.contains("{{ baibot_name }}")));
}
}
#[test]
fn pick_message_stays_within_the_active_tier() {
let deep = messages_for_elapsed(Duration::from_secs(120));
for sequence in 0..50 {
assert!(deep.contains(&pick_message(Duration::from_secs(120), sequence)));
}
}
#[test]
fn pick_message_never_repeats_on_consecutive_sequences() {
// The gesture-novelty guarantee: a monotonic sequence must not pick the same line twice
// in a row (and walks the whole tier before any line recurs).
let elapsed = Duration::from_secs(0);
let pool_len = messages_for_elapsed(elapsed).len();
for sequence in 0..(pool_len * 3) {
assert_ne!(
pick_message(elapsed, sequence),
pick_message(elapsed, sequence + 1),
);
}
}
}