use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::app::events::AppEvent;
use crate::entities::sampling::{ReasoningEffort, SamplingConfig};
use crate::shared::api::{ApiMessage, ChatChunk, ChatRequest, EngineBackend};
use crate::shared::config::AutoTitleMode;
use crate::shared::session_budget::SILENT_YIELDS_MAX;
use super::Orchestrator;
const TITLE_MAX_TOKENS: usize = 2048;
const TITLE_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TitleOrigin {
Requested,
Auto,
}
pub(super) struct TitleResult {
pub(super) chat_id: Uuid,
pub(super) text: Result<String, String>,
pub(super) origin: TitleOrigin,
pub(super) prefill: Option<crate::shared::api::contract::Prefill>,
}
impl Orchestrator {
pub(super) fn handle_auto_rename(&mut self, id: Uuid) {
self.start_title_task(id, TitleOrigin::Requested);
}
pub(super) fn maybe_auto_title(&mut self, id: Uuid, point: AutoTitleMode) {
if self.config.interface.auto_title != point {
return;
}
if self
.chats
.iter()
.find(|c| c.id == id)
.is_none_or(|c| c.renamed_manually)
{
return;
}
self.start_title_task(id, TitleOrigin::Auto);
}
pub(super) fn maybe_auto_title_run(&mut self, id: Uuid) {
if self.config.interface.auto_title == AutoTitleMode::Off {
return;
}
match self.view(id) {
Some(super::ChatView::Child { run, .. }) if !run.renamed_manually => {}
_ => return,
}
self.start_title_task(id, TitleOrigin::Auto);
}
fn report_title_error(&self, origin: TitleOrigin, msg: String) {
match origin {
TitleOrigin::Requested => {
let _ = self.evt_tx.send(AppEvent::ChatListError(msg));
}
TitleOrigin::Auto => tracing::warn!(error = %msg, "automatic chat titling skipped"),
}
}
fn start_title_task(&mut self, id: Uuid, origin: TitleOrigin) {
let (profile_id, messages) = match self.view(id) {
Some(super::ChatView::Top(chat)) => (chat.profile_id, chat.messages.clone()),
Some(super::ChatView::Child { parent, run }) => {
(parent.profile_id, run.messages.clone())
}
None => return,
};
let loc = self.profile_locale(profile_id);
let Some(digest) = crate::features::rename_chat::build_conversation_digest(&messages, loc)
else {
self.report_title_error(origin, self.ui_locale().t("ui.err.title_not_enough").into());
return;
};
let backend = match self.engines.backend_if_ready(self.ui_locale()) {
Ok(backend) => backend,
Err(msg) => {
self.report_title_error(origin, msg);
return;
}
};
let sampling = SamplingConfig {
max_tokens: Some(TITLE_MAX_TOKENS),
temperature: Some(0.3),
thinking: Some(false),
reasoning_effort: Some(ReasoningEffort::None),
reasoning_budget: Some(0),
..Default::default()
};
let request = ChatRequest {
continue_final: false,
system: Some(crate::features::rename_chat::title_system_message(loc)),
messages: vec![ApiMessage::user(digest)],
sampling,
tools: Vec::new(),
};
let sessions = self.session_budget();
spawn_title(
backend,
request,
id,
origin,
self.ui_locale(),
self.title_tx.clone(),
sessions,
);
}
pub(super) fn handle_title_result(&mut self, res: TitleResult) {
let prefill = res.prefill;
self.apply_title_result(res);
self.note_slow_prefill(prefill);
}
fn apply_title_result(&mut self, res: TitleResult) {
let raw = match res.text {
Ok(raw) => raw,
Err(msg) => {
self.report_title_error(res.origin, msg);
return;
}
};
let Some(title) = crate::features::rename_chat::clean_generated_title(&raw) else {
self.report_title_error(res.origin, self.ui_locale().t("ui.err.title_empty").into());
return;
};
if !self.apply_title(res.chat_id, res.origin, &title) {
return;
}
self.emit_chat_list();
let _ = self.evt_tx.send(AppEvent::ChatRenamed {
id: res.chat_id,
title,
});
}
fn apply_title(&mut self, chat_id: Uuid, origin: TitleOrigin, title: &str) -> bool {
if let Some(chat) = self.chat_mut(chat_id) {
if origin == TitleOrigin::Auto && chat.renamed_manually {
tracing::debug!(chat = %chat_id,
"automatic title dropped: the chat was renamed manually meanwhile");
return false;
}
chat.title = title.to_string();
self.mark_dirty(chat_id);
return true;
}
let mut dropped = false;
let found = self.with_child_mut(chat_id, |run| {
if origin == TitleOrigin::Auto && run.renamed_manually {
dropped = true;
} else {
run.title = title.to_string();
}
});
found && !dropped
}
}
fn spawn_title(
backend: Arc<dyn EngineBackend>,
request: ChatRequest,
chat_id: Uuid,
origin: TitleOrigin,
loc: &'static crate::shared::i18n::Locale,
title_tx: UnboundedSender<TitleResult>,
sessions: Arc<crate::shared::session_budget::SessionBudget>,
) {
tokio::spawn(async move {
let cancel = CancellationToken::new();
let estimate = super::generation::estimate_prompt_tokens(&request);
let need = sessions.price(
crate::shared::session_budget::Shape::Title,
estimate,
0,
request.sampling.max_tokens.map(|m| m as u64),
);
let mut yields: u32 = 0;
let mut prefill = None;
let text = loop {
let Some(lane) = sessions
.acquire_silent(need, &cancel, "title", yields < SILENT_YIELDS_MAX)
.await
else {
return;
};
let token = lane.stream_token();
let request = request.clone();
let collect = async {
let mut stream = backend.chat_stream(request, token.clone()).await?;
let mut text = String::new();
let mut thoughts = String::new();
let mut cancelled = false;
let mut sample = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Thoughts(t) => thoughts.push_str(&t),
ChatChunk::Finished(reason) => {
cancelled =
matches!(reason, crate::shared::api::FinishReason::Cancelled);
break;
}
ChatChunk::Retry {
attempt,
max,
delay,
} => {
tracing::info!(attempt, max, ?delay, "retrying a a title turn");
}
ChatChunk::Error { message, .. } => {
tracing::warn!(error = %message, "engine error while generating a title");
}
ChatChunk::Usage(u) => {
sessions.record_usage(
crate::shared::session_budget::Shape::Title,
estimate,
u.prompt_tokens as u64,
);
sample = u.prefill;
}
ChatChunk::ThoughtsSignature(_) | ChatChunk::ToolCall(_) => {}
}
}
Ok::<Collected, anyhow::Error>((text, thoughts, cancelled, sample))
};
match tokio::time::timeout(TITLE_TIMEOUT, collect).await {
Ok(Ok((_, _, true, sample))) if lane.displaced() => {
crate::shared::api::contract::Prefill::keep_larger(&mut prefill, sample);
yields += 1;
tracing::info!(
chat = %chat_id,
yields,
"the title's stream was displaced by an interactive one; made again"
);
}
Ok(Ok((_, _, true, _))) => return,
Ok(Ok((text, thoughts, false, sample))) => {
crate::shared::api::contract::Prefill::keep_larger(&mut prefill, sample);
break Ok(salvage_title_source(text, thoughts));
}
Ok(Err(err)) => {
break Err(loc.tf("ui.err.title_gen_failed", &[("err", &err.to_string())]));
}
Err(_) => {
cancel.cancel();
break Err(loc.t("ui.err.title_timeout").to_string());
}
}
};
let _ = title_tx.send(TitleResult {
chat_id,
text,
origin,
prefill,
});
});
}
type Collected = (
String,
String,
bool,
Option<crate::shared::api::contract::Prefill>,
);
pub(super) fn salvage_title_source(text: String, thoughts: String) -> String {
if !text.trim().is_empty() {
return text;
}
thoughts
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
}