microclaw_engine/
title_generator.rs1use std::sync::Arc;
10
11use crate::internal::storage::db::{call_blocking, Database};
12use microclaw_core::error::MicroClawError;
13use microclaw_core::llm_types::{Message, MessageContent, ResponseContentBlock};
14
15use crate::config::Config;
16use crate::llm::create_provider;
17
18const MIN_MESSAGES_BEFORE_TITLE: usize = 4;
19const MAX_MESSAGES_SAMPLED: usize = 8;
20
21pub async fn generate_and_save_title(
25 config: &Config,
26 db: Arc<Database>,
27 chat_id: i64,
28) -> Result<Option<String>, MicroClawError> {
29 let snapshot = call_blocking(db.clone(), move |d| {
30 let info = d.get_session_label_and_length(chat_id)?;
31 let messages = d.get_recent_messages(chat_id, MAX_MESSAGES_SAMPLED)?;
32 Ok::<_, MicroClawError>((info, messages))
33 })
34 .await?;
35 let (info, recent) = snapshot;
36 let Some((existing_label, total_len)) = info else {
37 return Ok(None);
38 };
39 if existing_label
40 .as_deref()
41 .map(|s| !s.trim().is_empty())
42 .unwrap_or(false)
43 {
44 return Ok(None);
45 }
46 if total_len < MIN_MESSAGES_BEFORE_TITLE {
47 return Ok(None);
48 }
49 if recent.is_empty() {
50 return Ok(None);
51 }
52
53 let transcript = build_transcript(&recent);
54 let provider = create_provider(config);
55 let system = "You generate a short session title — 3 to 8 English or Chinese words, no quotes, no punctuation at end. The title should capture the dominant topic. Do not explain.".to_string();
56 let user = format!(
57 "Produce a title for the following conversation. Reply with the title only.\n\n{transcript}"
58 );
59 let response = provider
62 .send_message_with_model(
63 &system,
64 vec![Message {
65 role: "user".to_string(),
66 content: MessageContent::Text(user),
67 }],
68 None,
69 config.aux_models.title_model(),
70 )
71 .await?;
72 let title = first_text(&response.content)
73 .map(|t| clean_title(&t))
74 .filter(|t| !t.is_empty());
75 let Some(title) = title else {
76 return Ok(None);
77 };
78 let title_for_db = title.clone();
79 call_blocking(db, move |d| d.set_session_label(chat_id, &title_for_db)).await?;
80 Ok(Some(title))
81}
82
83fn build_transcript(messages: &[crate::internal::storage::db::StoredMessage]) -> String {
84 let mut out = String::new();
85 for m in messages.iter().rev().take(MAX_MESSAGES_SAMPLED) {
86 let role = if m.is_from_bot { "Assistant" } else { "User" };
87 let trimmed = m.content.chars().take(1500).collect::<String>();
88 out.push_str(role);
89 out.push_str(": ");
90 out.push_str(&trimmed);
91 out.push_str("\n\n");
92 }
93 out
94}
95
96fn first_text(blocks: &[ResponseContentBlock]) -> Option<String> {
97 for block in blocks {
98 if let ResponseContentBlock::Text { text } = block {
99 if !text.trim().is_empty() {
100 return Some(text.clone());
101 }
102 }
103 }
104 None
105}
106
107fn clean_title(raw: &str) -> String {
108 let first_line = raw.lines().next().unwrap_or("");
109 let quote_chars: &[char] = &['"', '\'', '«', '»', '“', '”', '‘', '’'];
110 let end_punct: &[char] = &['.', '。', '!', '?', ';', ';'];
111 let cleaned: String = first_line
112 .trim()
113 .trim_matches(quote_chars)
114 .trim_end_matches(end_punct)
115 .chars()
116 .take(80)
117 .collect();
118 cleaned.trim().to_string()
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn cleans_quotes_and_trailing_punctuation() {
127 assert_eq!(
128 clean_title("\"Rust async deep dive.\""),
129 "Rust async deep dive"
130 );
131 assert_eq!(clean_title(" Hello world! "), "Hello world");
132 assert_eq!(clean_title("“写 Rust 好处”"), "写 Rust 好处");
133 }
134
135 #[test]
136 fn strips_extra_lines() {
137 assert_eq!(clean_title("Title here\nExplanation ..."), "Title here");
138 }
139
140 #[test]
141 fn caps_long_titles() {
142 let long = "a".repeat(200);
143 let out = clean_title(&long);
144 assert!(out.chars().count() <= 80);
145 }
146}