1use std::sync::Arc;
18use std::time::Duration;
19
20use bamboo_agent_core::{Message, Role, Session, TitleSource};
21use tokio::time::timeout;
22use tokio_util::sync::CancellationToken;
23use tracing::{debug, info, warn};
24
25use crate::app_context::AgentSessionContext;
26use crate::session_app::metadata::SessionMetadataService;
27
28mod fallback;
29mod prompt;
30#[cfg(test)]
31mod tests;
32
33pub use fallback::heuristic_title;
34pub use prompt::build_title_messages;
35
36const TITLE_GEN_TIMEOUT_SECS: u64 = 30;
37const MAX_TITLE_LEN: usize = 80;
38const MAX_USER_TEXT_FOR_TITLE: usize = 2000;
39
40pub fn spawn_title_generation(state: Arc<dyn AgentSessionContext>, session_id: String) {
43 spawn_inner(state, session_id, false);
44}
45
46pub fn spawn_title_generation_force(state: Arc<dyn AgentSessionContext>, session_id: String) {
49 spawn_inner(state, session_id, true);
50}
51
52fn spawn_inner(state: Arc<dyn AgentSessionContext>, session_id: String, force: bool) {
53 if !state.title_gen_acquire(&session_id) {
54 info!(session_id = %session_id, "title-gen: already in-flight, skipping");
55 return;
56 }
57 let state_for_task = state.clone();
58 let sid = session_id.clone();
59 tokio::spawn(async move {
60 let _guard = TitleGenGuard {
61 state: state_for_task.clone(),
62 session_id: sid.clone(),
63 };
64 if let Err(e) = run_title_generation(&state_for_task, &sid, force).await {
65 warn!(session_id = %sid, "title-gen failed: {e}");
66 }
67 });
68}
69
70struct TitleGenGuard {
71 state: Arc<dyn AgentSessionContext>,
72 session_id: String,
73}
74
75impl Drop for TitleGenGuard {
76 fn drop(&mut self) {
77 self.state.title_gen_release(&self.session_id);
78 }
79}
80
81pub fn is_untitled(title: &str) -> bool {
83 let s = title.trim();
84 if s.is_empty() {
85 return true;
86 }
87
88 if matches!(
90 s,
91 "New Session"
92 | "新建会话"
93 | "新建會話"
94 | "Nouvelle session"
95 | "新しいセッション"
96 | "नया सत्र"
97 ) {
98 return true;
99 }
100
101 s.strip_prefix("New Session - ")
106 .or_else(|| s.strip_prefix("New Session with "))
107 .or_else(|| s.strip_prefix("New session - "))
108 .or_else(|| s.strip_prefix("New session with "))
109 .map(|suffix| !suffix.trim().is_empty())
110 .unwrap_or(false)
111}
112
113async fn run_title_generation(
114 state: &Arc<dyn AgentSessionContext>,
115 session_id: &str,
116 force: bool,
117) -> Result<(), String> {
118 let session = state
120 .storage()
121 .load_session(session_id)
122 .await
123 .map_err(|e| format!("load_session: {e}"))?
124 .ok_or_else(|| "session not found".to_string())?;
125
126 if !force && !is_untitled(&session.title) {
128 debug!(session_id = %session_id, "title-gen: session already titled, skipping");
129 return Ok(());
130 }
131
132 let first_user = match first_user_text(&session) {
134 Some(t) if !t.trim().is_empty() => t,
135 _ => {
136 debug!(session_id = %session_id, "title-gen: no user message text, skipping");
137 return Ok(());
138 }
139 };
140 let first_user_len = first_user.len();
141
142 let (raw_title, source) = match try_llm_title(state, &session, &first_user).await {
144 Ok(t) if !t.trim().is_empty() => {
145 info!(session_id = %session_id, prompt_len = first_user_len, "title-gen: LLM produced title");
146 (t, TitleSource::Auto)
147 }
148 Ok(_) => {
149 warn!(session_id = %session_id, "title-gen: LLM returned empty, using fallback");
150 (heuristic_title(&first_user), TitleSource::Fallback)
151 }
152 Err(e) => {
153 warn!(session_id = %session_id, "title-gen: LLM failed ({e}), using fallback");
154 (heuristic_title(&first_user), TitleSource::Fallback)
155 }
156 };
157
158 let title = sanitize(&raw_title);
159 if title.is_empty() {
160 debug!(session_id = %session_id, "title-gen: sanitized title empty, skipping");
161 return Ok(());
162 }
163
164 match SessionMetadataService::apply_generated_title(
170 state.as_ref(),
171 session_id,
172 &title,
173 source,
174 force,
175 )
176 .await
177 .map_err(|e| format!("apply_generated_title: {e}"))?
178 {
179 Some((applied, title_version)) => {
180 info!(
181 session_id = %session_id,
182 title_len = applied.len(),
183 title_version,
184 source = ?source,
185 "title-gen: applied generated title"
186 );
187 }
188 None => {
189 debug!(
190 session_id = %session_id,
191 "title-gen: skipped (already titled or unchanged)"
192 );
193 }
194 }
195 Ok(())
196}
197
198fn first_user_text(session: &Session) -> Option<String> {
203 let msg = session.messages.iter().find(|m| {
204 matches!(m.role, Role::User) && !crate::session_app::execute::is_system_resume_message(m)
205 })?;
206 let raw = if let Some(parts) = msg.content_parts.as_ref() {
207 let joined: String = parts
208 .iter()
209 .filter_map(|p| match p {
210 bamboo_agent_core::MessagePart::Text { text } => Some(text.as_str()),
211 _ => None,
212 })
213 .collect::<Vec<_>>()
214 .join("\n");
215 if joined.trim().is_empty() {
216 msg.content.clone()
217 } else {
218 joined
219 }
220 } else {
221 msg.content.clone()
222 };
223 Some(raw.chars().take(MAX_USER_TEXT_FOR_TITLE).collect())
224}
225
226async fn try_llm_title(
230 state: &Arc<dyn AgentSessionContext>,
231 session: &Session,
232 first_user_text: &str,
233) -> Result<String, String> {
234 let config_snapshot = { state.config().read().await.clone() };
236 let provider_name = if let Some(ref m) = session.model_ref {
237 m.provider.clone()
238 } else if let Some(p) = session.provider_name() {
239 p
240 } else {
241 config_snapshot.provider.clone()
242 };
243
244 let resolved = crate::model_config_helper::resolve_fast_model(
245 &config_snapshot,
246 &provider_name,
247 state.provider_registry(),
248 )
249 .ok_or_else(|| "no fast model resolved".to_string())?;
250
251 let messages: Vec<Message> = build_title_messages(first_user_text);
252 let model_name = resolved.model_name.clone();
253 let provider = resolved.provider;
254
255 let fut = async move {
256 let options = bamboo_llm::provider::LLMRequestOptions {
257 request_purpose: Some("title_generation".to_string()),
258 ..Default::default()
259 };
260 let stream = provider
261 .chat_stream_with_options(&messages, &[], Some(64), &model_name, Some(&options))
262 .await
263 .map_err(|e| format!("chat_stream: {e}"))?;
264 let output = crate::runtime::stream::handler::consume_llm_stream_silent(
265 stream,
266 &CancellationToken::new(),
267 "title-gen",
268 )
269 .await
270 .map_err(|e| format!("consume_llm_stream_silent: {e}"))?;
271 Ok::<String, String>(output.content)
272 };
273
274 timeout(Duration::from_secs(TITLE_GEN_TIMEOUT_SECS), fut)
275 .await
276 .map_err(|_| "title-gen LLM timeout".to_string())?
277}
278
279fn sanitize(input: &str) -> String {
282 let first_line = input.lines().next().unwrap_or("").trim();
283 let trim_chars: &[char] = &['"', '\'', '`', '.', ',', ';', ':', ' ', '\t'];
284 let stripped = first_line.trim_matches(trim_chars).trim();
285 let limit = stripped
286 .char_indices()
287 .nth(MAX_TITLE_LEN)
288 .map(|(i, _)| i)
289 .unwrap_or(stripped.len());
290 stripped[..limit].trim().to_string()
291}