Skip to main content

matrixcode_core/
session.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use uuid::Uuid;
6
7use crate::compress::CompressionHistoryEntry;
8use crate::providers::Message;
9
10/// Session metadata stored in the index.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionMetadata {
13    /// Unique session identifier (UUID).
14    pub id: String,
15    /// User-defined session name (optional).
16    pub name: Option<String>,
17    /// Project path this session is associated with (optional).
18    pub project_path: Option<String>,
19    /// When the session was created.
20    pub created_at: DateTime<Utc>,
21    /// When the session was last updated.
22    pub updated_at: DateTime<Utc>,
23    /// Number of messages in the session.
24    pub message_count: usize,
25    /// Last input tokens reported.
26    pub last_input_tokens: u64,
27    /// Cumulative output tokens.
28    pub total_output_tokens: u64,
29    /// Compression history entries.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub compression_history: Vec<CompressionHistoryEntry>,
32}
33
34impl SessionMetadata {
35    /// Create a new session metadata with a fresh UUID and auto-generated name.
36    pub fn new(project_path: Option<&Path>) -> Self {
37        let now = Utc::now();
38        Self {
39            id: Uuid::new_v4().to_string(),
40            name: None, // Will be auto-generated from first meaningful user message
41            project_path: project_path.map(|p| p.to_string_lossy().to_string()),
42            created_at: now,
43            updated_at: now,
44            message_count: 0,
45            last_input_tokens: 0,
46            total_output_tokens: 0,
47            compression_history: Vec::new(),
48        }
49    }
50
51    /// Generate a friendly time-based name for the session.
52    /// Format: "YYYY-MM-DD HH:mm" (e.g., "2024-01-15 14:30")
53    fn generate_time_name(time: DateTime<Utc>) -> String {
54        // Use local timezone for display
55        let local: chrono::DateTime<chrono::Local> = time.with_timezone(&chrono::Local);
56        local.format("%Y-%m-%d %H:%M").to_string()
57    }
58
59    /// Add a compression entry to history.
60    pub fn add_compression_entry(&mut self, entry: CompressionHistoryEntry) {
61        self.compression_history.push(entry);
62        // Keep only last 10 entries to avoid bloat
63        if self.compression_history.len() > 10 {
64            self.compression_history.remove(0);
65        }
66    }
67
68    /// Get total tokens saved across all compressions.
69    pub fn total_tokens_saved(&self) -> u32 {
70        self.compression_history
71            .iter()
72            .map(|e| e.tokens_saved)
73            .sum()
74    }
75
76    /// Get compression count.
77    pub fn compression_count(&self) -> usize {
78        self.compression_history.len()
79    }
80
81    /// Get a display name for the session.
82    /// Returns user-defined name if set, otherwise a time-based fallback.
83    pub fn display_name(&self) -> String {
84        if let Some(ref name) = self.name {
85            name.clone()
86        } else {
87            // Fallback: show creation time
88            Self::generate_time_name(self.created_at)
89        }
90    }
91
92    /// Get a short ID for the session (first 8 chars of UUID).
93    pub fn short_id(&self) -> String {
94        self.id[..8].to_string()
95    }
96
97    /// Format the session for display in a list.
98    pub fn format_line(&self, is_current: bool) -> String {
99        let marker = if is_current { "*" } else { " " };
100        let name = self.display_name();
101        let msgs = self.message_count;
102        let project = self
103            .project_path
104            .as_ref()
105            .map(|p| {
106                // Show just the directory name, not full path
107                PathBuf::from(p)
108                    .file_name()
109                    .map(|n| n.to_string_lossy().to_string())
110                    .unwrap_or_else(|| p.clone())
111            })
112            .unwrap_or_else(|| "-".to_string());
113
114        // Add compression info if any
115        let compression_info = if self.compression_count() > 0 {
116            format!("  💾 {} comps", self.compression_count())
117        } else {
118            "".to_string()
119        };
120
121        format!(
122            "{} {}  {} msgs  {}{}",
123            marker, name, msgs, project, compression_info
124        )
125    }
126}
127
128/// Index of all sessions, stored in ~/.matrix/sessions/index.json
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct SessionIndex {
131    /// All known sessions.
132    pub sessions: Vec<SessionMetadata>,
133    /// ID of the most recently active session (for --continue).
134    pub last_session_id: Option<String>,
135}
136
137impl SessionIndex {
138    /// Find a session by ID or name.
139    pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
140        // First try exact ID match
141        if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
142            return Some(s);
143        }
144        // Then try exact name match
145        if let Some(s) = self
146            .sessions
147            .iter()
148            .find(|s| s.name.as_deref() == Some(query))
149        {
150            return Some(s);
151        }
152        // Then try partial ID match (for convenience)
153        if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
154            return Some(s);
155        }
156        None
157    }
158
159    /// Get the last session (for --continue).
160    pub fn last_session(&self) -> Option<&SessionMetadata> {
161        self.last_session_id
162            .as_ref()
163            .and_then(|id| self.sessions.iter().find(|s| s.id == *id))
164    }
165
166    /// Add or update a session in the index.
167    pub fn upsert(&mut self, meta: SessionMetadata) {
168        // Remove existing entry with same ID
169        self.sessions.retain(|s| s.id != meta.id);
170        // Add new entry
171        self.sessions.push(meta.clone());
172        // Update last_session_id
173        self.last_session_id = Some(meta.id);
174        // Sort by updated_at descending (most recent first)
175        self.sessions
176            .sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
177    }
178
179    /// Remove a session from the index.
180    pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
181        let removed = self.sessions.iter().position(|s| s.id == id);
182        if let Some(idx) = removed {
183            let meta = self.sessions.remove(idx);
184            if self.last_session_id.as_deref() == Some(id) {
185                self.last_session_id = self.sessions.first().map(|s| s.id.clone());
186            }
187            Some(meta)
188        } else {
189            None
190        }
191    }
192
193    /// Rename a session.
194    pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
195        let session = self.sessions.iter_mut().find(|s| s.id == id);
196        if let Some(s) = session {
197            s.name = Some(new_name.to_string());
198            s.updated_at = Utc::now();
199            Ok(())
200        } else {
201            anyhow::bail!("session {} not found", id)
202        }
203    }
204}
205
206/// Message summary for display (lightweight version).
207/// Used when full message content is compressed but user still needs to see history.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct MessageSummary {
210    /// Role of the message sender.
211    pub role: String,
212    /// Brief preview of content (truncated).
213    pub preview: String,
214    /// Timestamp (if available).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub timestamp: Option<DateTime<Utc>>,
217    /// Whether this message was compressed.
218    pub is_compressed: bool,
219    /// Original message index before compression.
220    pub original_index: usize,
221}
222
223impl MessageSummary {
224    /// Create a summary from a message.
225    pub fn from_message(msg: &Message, index: usize) -> Self {
226        use crate::providers::{ContentBlock, MessageContent, Role};
227        use crate::truncate::truncate_chars;
228
229        let role = match msg.role {
230            Role::User => "user",
231            Role::Assistant => "assistant",
232            Role::Tool => "tool",
233            Role::System => "system",
234        };
235
236        let preview = match &msg.content {
237            MessageContent::Text(t) => truncate_chars(t, 100),
238            MessageContent::Blocks(blocks) => {
239                let parts: Vec<String> = blocks
240                    .iter()
241                    .take(3)
242                    .map(|b| match b {
243                        ContentBlock::Text { text } => truncate_chars(text, 50),
244                        ContentBlock::ToolUse { name, .. } => format!("[{}]", name),
245                        ContentBlock::ToolResult { content, .. } => truncate_chars(content, 50),
246                        ContentBlock::Thinking { thinking, .. } => format!("💭 {}", truncate_chars(thinking, 30)),
247                        _ => "...".to_string(),
248                    })
249                    .collect();
250                parts.join(" ")
251            }
252        };
253
254        Self {
255            role: role.to_string(),
256            preview,
257            timestamp: None,
258            is_compressed: false,
259            original_index: index,
260        }
261    }
262}
263
264/// Full session data including messages.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Session {
267    pub metadata: SessionMetadata,
268    /// Full message history for display (TUI shows this).
269    #[serde(default)]
270    pub full_messages: Vec<Message>,
271    /// Compressed messages for API requests (Agent uses this).
272    /// If empty, use full_messages (no compression happened).
273    #[serde(default)]
274    pub compressed_messages: Vec<Message>,
275    /// Summaries of compressed messages (for TUI history view).
276    #[serde(default)]
277    pub message_summaries: Vec<MessageSummary>,
278    /// Legacy field - migrated to full_messages on load.
279    #[serde(default, skip_serializing)]
280    pub messages: Vec<Message>,
281}
282
283impl Session {
284    /// Create a new empty session.
285    pub fn new(project_path: Option<&Path>) -> Self {
286        Self {
287            metadata: SessionMetadata::new(project_path),
288            full_messages: Vec::new(),
289            compressed_messages: Vec::new(),
290            message_summaries: Vec::new(),
291            messages: Vec::new(),
292        }
293    }
294
295    /// Create a session from existing messages.
296    pub fn from_messages(messages: Vec<Message>, project_path: Option<&Path>) -> Self {
297        let mut meta = SessionMetadata::new(project_path);
298        meta.message_count = messages.len();
299        Self {
300            metadata: meta,
301            full_messages: messages.clone(),
302            compressed_messages: Vec::new(),
303            message_summaries: messages.iter().enumerate()
304                .map(|(i, m)| MessageSummary::from_message(m, i))
305                .collect(),
306            messages: messages,
307        }
308    }
309
310    /// Get messages for API requests (use compressed if available).
311    pub fn api_messages(&self) -> &[Message] {
312        if self.compressed_messages.is_empty() {
313            &self.full_messages
314        } else {
315            &self.compressed_messages
316        }
317    }
318
319    /// Get messages for display (always full messages).
320    pub fn display_messages(&self) -> &[Message] {
321        &self.full_messages
322    }
323
324    /// Update metadata after a turn.
325    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
326        self.metadata.message_count = self.full_messages.len();
327        self.metadata.last_input_tokens = last_input_tokens as u64;
328        self.metadata.total_output_tokens = total_output_tokens;
329        self.metadata.updated_at = Utc::now();
330    }
331
332    /// Set compressed messages (called after compression).
333    pub fn set_compressed(&mut self, compressed: Vec<Message>, summaries: Vec<MessageSummary>) {
334        self.compressed_messages = compressed;
335        self.message_summaries = summaries;
336    }
337
338    /// Migrate legacy messages field to full_messages.
339    fn migrate_legacy(&mut self) {
340        if !self.messages.is_empty() && self.full_messages.is_empty() {
341            self.full_messages = self.messages.clone();
342            self.message_summaries = self.messages.iter().enumerate()
343                .map(|(i, m)| MessageSummary::from_message(m, i))
344                .collect();
345            self.messages.clear();
346        }
347    }
348}
349
350/// Manager for session storage.
351pub struct SessionManager {
352    /// Base directory for session storage (~/.matrix).
353    base_dir: PathBuf,
354    /// Current active session (if any).
355    current_session: Option<Session>,
356    /// Session index.
357    index: SessionIndex,
358}
359
360impl SessionManager {
361    /// Create a new session manager.
362    pub fn new() -> Result<Self> {
363        let base_dir = Self::get_base_dir()?;
364        let manager = Self {
365            base_dir,
366            current_session: None,
367            index: SessionIndex::default(),
368        };
369        manager.ensure_dirs()?;
370        let mut manager = manager;
371        manager.load_index()?;
372        Ok(manager)
373    }
374
375    /// Get the base directory for session storage.
376    fn get_base_dir() -> Result<PathBuf> {
377        let home = std::env::var_os("HOME")
378            .or_else(|| std::env::var_os("USERPROFILE"))
379            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
380        let mut p = PathBuf::from(home);
381        p.push(".matrix");
382        Ok(p)
383    }
384
385    /// Get the sessions directory.
386    fn sessions_dir(&self) -> PathBuf {
387        self.base_dir.join("sessions")
388    }
389
390    /// Get the index file path.
391    fn index_path(&self) -> PathBuf {
392        self.sessions_dir().join("index.json")
393    }
394
395    /// Get the path for a specific session file.
396    fn session_path(&self, id: &str) -> PathBuf {
397        self.sessions_dir().join(format!("{}.json", id))
398    }
399
400    /// Ensure directories exist.
401    fn ensure_dirs(&self) -> Result<()> {
402        std::fs::create_dir_all(&self.base_dir)
403            .with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
404        std::fs::create_dir_all(self.sessions_dir())
405            .with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
406        Ok(())
407    }
408
409    /// Load the session index from disk.
410    fn load_index(&mut self) -> Result<()> {
411        let path = self.index_path();
412        if !path.exists() {
413            return Ok(());
414        }
415        let data = std::fs::read_to_string(&path)
416            .with_context(|| format!("reading index file {}", path.display()))?;
417        if data.trim().is_empty() {
418            return Ok(());
419        }
420        self.index = serde_json::from_str(&data)
421            .with_context(|| format!("parsing index file {}", path.display()))?;
422        Ok(())
423    }
424
425    /// Save the session index to disk.
426    fn save_index(&self) -> Result<()> {
427        let path = self.index_path();
428        let json =
429            serde_json::to_string_pretty(&self.index).context("serializing session index")?;
430        let tmp = path.with_extension("json.tmp");
431        std::fs::write(&tmp, json)
432            .with_context(|| format!("writing index tmp file {}", tmp.display()))?;
433        std::fs::rename(&tmp, &path)
434            .with_context(|| format!("renaming index tmp file to {}", path.display()))?;
435        Ok(())
436    }
437
438    /// Start a new session.
439    pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
440        let session = Session::new(project_path);
441        self.current_session = Some(session);
442        self.save_current()?;
443        // SAFETY: current_session was just set and save_current succeeded
444        Ok(self.current_session.as_ref().unwrap())
445    }
446
447    /// Continue the last session (for --continue).
448    pub fn continue_last(&mut self, project_path: Option<&Path>) -> Result<Option<&Session>> {
449        let last_id = self.index.last_session().map(|m| m.id.clone());
450        if let Some(id) = last_id {
451            self.load_session(&id)?;
452            // Update project path if provided and different
453            if let Some(path) = project_path
454                && let Some(ref mut session) = self.current_session
455            {
456                session.metadata.project_path = Some(path.to_string_lossy().to_string());
457            }
458            Ok(self.current_session.as_ref())
459        } else {
460            Ok(None)
461        }
462    }
463
464    /// Resume a specific session by ID or name (for --resume).
465    pub fn resume(&mut self, query: &str, project_path: Option<&Path>) -> Result<Option<&Session>> {
466        let session_id = self.index.find(query).map(|m| m.id.clone());
467        if let Some(id) = session_id {
468            self.load_session(&id)?;
469            // Update project path if provided
470            if let Some(path) = project_path
471                && let Some(ref mut session) = self.current_session
472            {
473                session.metadata.project_path = Some(path.to_string_lossy().to_string());
474            }
475            Ok(self.current_session.as_ref())
476        } else {
477            Ok(None)
478        }
479    }
480
481    /// Load a session from disk by ID.
482    fn load_session(&mut self, id: &str) -> Result<()> {
483        let path = self.session_path(id);
484        if !path.exists() {
485            anyhow::bail!("session file {} not found", path.display());
486        }
487        let data = std::fs::read_to_string(&path)
488            .with_context(|| format!("reading session file {}", path.display()))?;
489        let mut session: Session = serde_json::from_str(&data)
490            .with_context(|| format!("parsing session file {}", path.display()))?;
491
492        // Migrate legacy messages field to full_messages
493        session.migrate_legacy();
494
495        // If session name is null but index has a name, use index's name
496        if session.metadata.name.is_none()
497            && let Some(index_meta) = self.index.find(id)
498        {
499            session.metadata.name = index_meta.name.clone();
500        }
501
502        self.current_session = Some(session);
503        Ok(())
504    }
505
506    /// Save the current session to disk.
507    pub fn save_current(&mut self) -> Result<()> {
508        if let Some(ref session) = self.current_session {
509            // Update index first (if index save fails, session file won't be updated)
510            self.index.upsert(session.metadata.clone());
511            self.save_index()?;
512
513            // Now save session file
514            let path = self.session_path(&session.metadata.id);
515            let json = serde_json::to_string(session).context("serializing session")?;
516            let tmp = path.with_extension("json.tmp");
517            std::fs::write(&tmp, json)
518                .with_context(|| format!("writing session tmp file {}", tmp.display()))?;
519            std::fs::rename(&tmp, &path)
520                .with_context(|| format!("renaming session tmp file to {}", path.display()))?;
521        }
522        Ok(())
523    }
524
525    /// Update current session stats after a turn.
526    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
527        if let Some(ref mut session) = self.current_session {
528            session.update_stats(last_input_tokens, total_output_tokens);
529        }
530    }
531
532    /// Record a compression event in the session history.
533    pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
534        if let Some(ref mut session) = self.current_session {
535            session.metadata.add_compression_entry(entry);
536        }
537    }
538
539    /// Set messages for the current session.
540    pub fn set_messages(&mut self, messages: Vec<Message>) {
541        if let Some(ref mut session) = self.current_session {
542            // Auto-generate name from first user message if name is None
543            if session.metadata.name.is_none()
544                && !messages.is_empty()
545                && let Some(name) = Self::generate_name_from_messages(&messages)
546            {
547                session.metadata.name = Some(name);
548            }
549
550            // Update both full_messages and summaries
551            session.full_messages = messages.clone();
552            session.message_summaries = messages.iter().enumerate()
553                .map(|(i, m)| MessageSummary::from_message(m, i))
554                .collect();
555            session.metadata.message_count = session.full_messages.len();
556            session.metadata.updated_at = Utc::now();
557        }
558    }
559
560    /// Set compressed messages for the current session.
561    pub fn set_compressed_messages(&mut self, compressed: Vec<Message>) {
562        if let Some(ref mut session) = self.current_session {
563            // Mark all summaries as compressed first
564            for summary in &mut session.message_summaries {
565                summary.is_compressed = true;
566            }
567
568            // Then mark summaries as NOT compressed if their original message is in compressed version
569            // Compare by role and content preview (since Message doesn't implement PartialEq)
570            for compressed_msg in &compressed {
571                for (idx, full_msg) in session.full_messages.iter().enumerate() {
572                    // Simple comparison: same role and similar content
573                    if session.message_summaries.get(idx).is_some() {
574                        let same_role = compressed_msg.role == full_msg.role;
575                        if same_role {
576                            // Mark as not compressed
577                            if let Some(summary) = session.message_summaries.get_mut(idx) {
578                                summary.is_compressed = false;
579                            }
580                        }
581                    }
582                }
583            }
584
585            session.compressed_messages = compressed;
586        }
587    }
588
589    /// Get messages for API requests (compressed if available).
590    pub fn api_messages(&self) -> Option<&[Message]> {
591        self.current_session.as_ref().map(|s| s.api_messages())
592    }
593
594    /// Get messages for display (always full messages).
595    pub fn display_messages(&self) -> Option<&[Message]> {
596        self.current_session.as_ref().map(|s| s.display_messages())
597    }
598
599    /// Generate a human-readable session name from the first user message.
600    /// Takes the first meaningful user input and truncates it.
601    fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
602        use crate::providers::{ContentBlock, MessageContent, Role};
603
604        // Find first meaningful user message (skip very short/generic ones)
605        let user_messages: Vec<&Message> =
606            messages.iter().filter(|m| m.role == Role::User).collect();
607
608        for msg in user_messages.iter().take(3) {
609            let text = match &msg.content {
610                MessageContent::Text(t) => t.clone(),
611                MessageContent::Blocks(blocks) => blocks
612                    .iter()
613                    .filter_map(|b| {
614                        if let ContentBlock::Text { text } = b {
615                            Some(text.clone())
616                        } else {
617                            None
618                        }
619                    })
620                    .collect::<Vec<_>>()
621                    .join(" "),
622            };
623
624            let cleaned = text.trim().lines().next().unwrap_or("").trim();
625
626            // Skip too short or generic messages
627            if cleaned.len() < 5 || is_generic_message(cleaned) {
628                continue;
629            }
630
631            // Truncate to reasonable length for display
632            let name = if cleaned.chars().count() > 40 {
633                let truncated: String = cleaned.chars().take(37).collect();
634                format!("{}...", truncated)
635            } else {
636                cleaned.to_string()
637            };
638
639            return Some(name);
640        }
641
642        None
643    }
644
645    /// Get the current session's messages (for API - compressed if available).
646    pub fn messages(&self) -> Option<&[Message]> {
647        self.current_session.as_ref().map(|s| s.api_messages())
648    }
649
650    /// Get mutable reference to messages (returns full_messages for editing).
651    pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
652        self.current_session.as_mut().map(|s| &mut s.full_messages)
653    }
654
655    /// Get full messages for display (TUI).
656    pub fn full_messages(&self) -> Option<&[Message]> {
657        self.current_session.as_ref().map(|s| s.display_messages())
658    }
659
660    /// Get the current session ID.
661    pub fn current_id(&self) -> Option<&str> {
662        self.current_session
663            .as_ref()
664            .map(|s| s.metadata.id.as_str())
665    }
666
667    /// Get the current session name.
668    pub fn current_name(&self) -> Option<&str> {
669        self.current_session.as_ref().and_then(|s| s.name())
670    }
671
672    /// Rename the current session.
673    pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
674        if let Some(ref session) = self.current_session {
675            let id = session.metadata.id.clone();
676            self.index.rename(&id, new_name)?;
677            if let Some(ref mut session) = self.current_session {
678                session.metadata.name = Some(new_name.to_string());
679            }
680            self.save_current()?;
681        }
682        Ok(())
683    }
684
685    /// Clear the current session (start fresh).
686    pub fn clear_current(&mut self) -> Result<()> {
687        if let Some(ref session) = self.current_session {
688            // Remove session file
689            let path = self.session_path(&session.metadata.id);
690            let _ = std::fs::remove_file(&path);
691            // Remove from index
692            self.index.remove(&session.metadata.id);
693            self.save_index()?;
694        }
695        self.current_session = None;
696        Ok(())
697    }
698
699    /// List all sessions.
700    pub fn list_sessions(&self) -> &[SessionMetadata] {
701        &self.index.sessions
702    }
703
704    /// Check if there's a current session.
705    pub fn has_current(&self) -> bool {
706        self.current_session.is_some()
707    }
708
709    /// Get current session metadata.
710    pub fn current_metadata(&self) -> Option<&SessionMetadata> {
711        self.current_session.as_ref().map(|s| &s.metadata)
712    }
713
714    /// Get the history file path (legacy compatibility).
715    pub fn history_path(&self) -> PathBuf {
716        self.base_dir.join("history.txt")
717    }
718}
719
720impl Session {
721    /// Get the session name (user-defined or fallback).
722    pub fn name(&self) -> Option<&str> {
723        self.metadata.name.as_deref()
724    }
725}
726
727use anyhow::Context;
728
729/// Check if a message is too generic to be a good session name.
730fn is_generic_message(msg: &str) -> bool {
731    let generic = [
732        "继续", "好的", "ok", "yes", "no", "是", "否", "嗯", "对", "行", "可以", "好", "谢谢",
733        "thanks", "hi", "hello", "你好", "开始", "start",
734    ];
735    generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
736}