1use anyhow::Result;
2use futures_util::StreamExt;
3use inquire::Text;
4use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled};
5use std::io::{self, Write};
6
7use super::command::{Input, SlashCommand, SlashCommandCompleter, parse_input};
8use super::ui;
9use crate::translation::{TranslationClient, TranslationRequest};
10use crate::ui::Spinner;
11
12#[derive(Debug, Clone)]
14pub struct SessionConfig {
15 pub provider_name: String,
17 pub endpoint: String,
19 pub model: String,
21 pub api_key: Option<String>,
23 pub to: String,
25}
26
27impl SessionConfig {
28 pub const fn new(
30 provider_name: String,
31 endpoint: String,
32 model: String,
33 api_key: Option<String>,
34 to: String,
35 ) -> Self {
36 Self {
37 provider_name,
38 endpoint,
39 model,
40 api_key,
41 to,
42 }
43 }
44}
45
46pub struct ChatSession {
50 config: SessionConfig,
51 client: TranslationClient,
52}
53
54impl ChatSession {
55 pub fn new(config: SessionConfig) -> Self {
57 let client = TranslationClient::new(config.endpoint.clone(), config.api_key.clone());
58 Self { config, client }
59 }
60
61 pub async fn run(&mut self) -> Result<()> {
62 ui::print_header();
63
64 let prompt_style = Styled::new("❯")
65 .with_fg(Color::LightBlue)
66 .with_attr(Attributes::BOLD);
67 let mut render_config = RenderConfig::default()
68 .with_prompt_prefix(prompt_style)
69 .with_answered_prompt_prefix(prompt_style);
70
71 render_config.option = StyleSheet::new().with_fg(Color::Grey);
73 render_config.selected_option = Some(StyleSheet::new().with_fg(Color::DarkMagenta));
75
76 loop {
77 let input = Text::new("")
78 .with_render_config(render_config)
79 .with_autocomplete(SlashCommandCompleter)
80 .with_help_message("Type text to translate, /help for commands, Ctrl+C to quit")
81 .prompt();
82
83 match input {
84 Ok(line) => match parse_input(&line) {
85 Input::Empty => {}
86 Input::Command(cmd) => {
87 if !self.handle_command(cmd) {
88 break;
89 }
90 }
91 Input::Text(text) => {
92 self.translate_and_print(&text).await?;
93 }
94 },
95 Err(
96 inquire::InquireError::OperationCanceled
97 | inquire::InquireError::OperationInterrupted,
98 ) => {
99 println!(); break;
101 }
102 Err(e) => return Err(e.into()),
103 }
104 }
105
106 ui::print_goodbye();
107 Ok(())
108 }
109
110 fn handle_command(&self, cmd: SlashCommand) -> bool {
111 match cmd {
112 SlashCommand::Config => {
113 ui::print_config(&self.config);
114 true
115 }
116 SlashCommand::Help => {
117 ui::print_help();
118 true
119 }
120 SlashCommand::Quit => false,
121 SlashCommand::Unknown(cmd) => {
122 ui::print_error(&format!("Unknown command: /{cmd}"));
123 true
124 }
125 }
126 }
127
128 async fn translate_and_print(&self, text: &str) -> Result<()> {
129 let request = TranslationRequest {
130 source_text: text.to_string(),
131 target_language: self.config.to.clone(),
132 model: self.config.model.clone(),
133 endpoint: self.config.endpoint.clone(),
134 };
135
136 let spinner = Spinner::new("Translating...");
137
138 let mut stream = self.client.translate_stream(&request).await?;
139 let mut first_chunk = true;
140
141 while let Some(chunk_result) = stream.next().await {
142 let chunk = chunk_result?;
143
144 if first_chunk {
145 spinner.stop();
146 first_chunk = false;
147 }
148
149 print!("{chunk}");
150 io::stdout().flush()?;
151 }
152
153 if first_chunk {
154 spinner.stop();
155 }
156
157 println!();
158 println!();
159 Ok(())
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn test_session_config_new() {
169 let config = SessionConfig::new(
170 "ollama".to_string(),
171 "http://localhost:11434".to_string(),
172 "gemma3:12b".to_string(),
173 None,
174 "ja".to_string(),
175 );
176
177 assert_eq!(config.provider_name, "ollama");
178 assert_eq!(config.endpoint, "http://localhost:11434");
179 assert_eq!(config.model, "gemma3:12b");
180 assert!(config.api_key.is_none());
181 assert_eq!(config.to, "ja");
182 }
183}