v0.9
cleanup, remove thinking checkbox, add pulldown-cmark and config
This commit is contained in:
parent
24990f514f
commit
65e5364320
15 changed files with 1476 additions and 968 deletions
21
Cargo.toml
21
Cargo.toml
|
|
@ -4,17 +4,14 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# GTK4 and GLib
|
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||||
|
glib = "0.20"
|
||||||
# HTTP client with JSON support
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
|
||||||
# Async runtime and utilities
|
|
||||||
tokio = { version = "1.0", features = ["rt-multi-thread", "time"] }
|
|
||||||
futures-util = "0.3"
|
|
||||||
async-channel = "2.1"
|
|
||||||
|
|
||||||
# Serialization
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5.0"
|
||||||
|
futures-util = "0.3"
|
||||||
|
async-channel = "2.3"
|
||||||
|
pulldown-cmark = { version = "0.13.0", default-features = false, features = ["html"] }
|
||||||
34
src/api.rs
34
src/api.rs
|
|
@ -17,8 +17,7 @@ pub async fn send_chat_request_streaming(
|
||||||
model: &str,
|
model: &str,
|
||||||
conversation: &Arc<Mutex<Vec<ChatMessage>>>,
|
conversation: &Arc<Mutex<Vec<ChatMessage>>>,
|
||||||
token_sender: async_channel::Sender<String>,
|
token_sender: async_channel::Sender<String>,
|
||||||
thinking_enabled: bool,
|
) -> Result<(String, Option<String>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let messages = {
|
let messages = {
|
||||||
let conversation = conversation.lock().unwrap();
|
let conversation = conversation.lock().unwrap();
|
||||||
conversation.iter().cloned().collect::<Vec<_>>()
|
conversation.iter().cloned().collect::<Vec<_>>()
|
||||||
|
|
@ -28,7 +27,6 @@ pub async fn send_chat_request_streaming(
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
messages,
|
messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
think: if thinking_enabled { Some(true) } else { Some(false) },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
|
@ -51,7 +49,7 @@ pub async fn send_chat_request_streaming(
|
||||||
let mut full_response = String::new();
|
let mut full_response = String::new();
|
||||||
let mut current_batch = String::new();
|
let mut current_batch = String::new();
|
||||||
let mut tokens_since_last_send = 0;
|
let mut tokens_since_last_send = 0;
|
||||||
const BATCH_SIZE: usize = 20; // Reasonable batch size
|
const BATCH_SIZE: usize = 20;
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(100);
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
let mut last_send = tokio::time::Instant::now();
|
let mut last_send = tokio::time::Instant::now();
|
||||||
|
|
@ -80,19 +78,19 @@ pub async fn send_chat_request_streaming(
|
||||||
|| last_send.elapsed() >= BATCH_TIMEOUT
|
|| last_send.elapsed() >= BATCH_TIMEOUT
|
||||||
|| stream_response.done;
|
|| stream_response.done;
|
||||||
|
|
||||||
if should_send && !current_batch.is_empty() {
|
if should_send {
|
||||||
// Use non-blocking send with async-channel
|
// Send content batch
|
||||||
match token_sender.send(current_batch.clone()).await {
|
if !current_batch.is_empty() {
|
||||||
Ok(_) => {
|
match token_sender.send(current_batch.clone()).await {
|
||||||
current_batch.clear();
|
Ok(_) => {
|
||||||
tokens_since_last_send = 0;
|
current_batch.clear();
|
||||||
last_send = tokio::time::Instant::now();
|
tokens_since_last_send = 0;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => break,
|
||||||
// Channel closed, stop sending but continue processing
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
last_send = tokio::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
if stream_response.done {
|
if stream_response.done {
|
||||||
|
|
@ -108,17 +106,17 @@ pub async fn send_chat_request_streaming(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send any remaining tokens in the batch
|
// Send any remaining tokens in the batches
|
||||||
if !current_batch.is_empty() {
|
if !current_batch.is_empty() {
|
||||||
let _ = token_sender.send(current_batch).await;
|
let _ = token_sender.send(current_batch).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async_channel automatically closes when sender is dropped
|
// Close channels
|
||||||
drop(token_sender);
|
drop(token_sender);
|
||||||
|
|
||||||
if full_response.is_empty() {
|
if full_response.is_empty() {
|
||||||
return Err("No response received from the model".into());
|
return Err("No response received from the model".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(full_response)
|
Ok((full_response, None))
|
||||||
}
|
}
|
||||||
168
src/app.rs
Normal file
168
src/app.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Application, ApplicationWindow};
|
||||||
|
use gtk4::Orientation;
|
||||||
|
use gtk4::Box as GtkBox;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use crate::state::{AppState, SharedState};
|
||||||
|
use crate::ui::{chat, input, controls, handlers};
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub fn build_ui(app: &Application) {
|
||||||
|
let window = ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title("Ollama Chat")
|
||||||
|
.default_width(900)
|
||||||
|
.default_height(700)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Initialize shared state (this loads config)
|
||||||
|
let shared_state: SharedState = Rc::new(RefCell::new(AppState::default()));
|
||||||
|
|
||||||
|
// Setup CSS with config
|
||||||
|
setup_css(&window, &shared_state.borrow().config);
|
||||||
|
|
||||||
|
// Create main container with proper spacing
|
||||||
|
let main_container = GtkBox::new(Orientation::Vertical, 12);
|
||||||
|
main_container.set_margin_top(16);
|
||||||
|
main_container.set_margin_bottom(16);
|
||||||
|
main_container.set_margin_start(16);
|
||||||
|
main_container.set_margin_end(16);
|
||||||
|
|
||||||
|
// Create UI components
|
||||||
|
let chat_view = chat::create_chat_view();
|
||||||
|
let input_area = input::create_input_area();
|
||||||
|
let controls_area = controls::create_controls();
|
||||||
|
|
||||||
|
// Set proper expansion properties
|
||||||
|
// Chat view should expand to fill available space
|
||||||
|
chat_view.widget().set_vexpand(true);
|
||||||
|
chat_view.widget().set_hexpand(true);
|
||||||
|
|
||||||
|
// Input area should not expand vertically but should expand horizontally
|
||||||
|
input_area.container.set_vexpand(false);
|
||||||
|
input_area.container.set_hexpand(true);
|
||||||
|
|
||||||
|
// Controls should not expand
|
||||||
|
controls_area.container.set_vexpand(false);
|
||||||
|
controls_area.container.set_hexpand(true);
|
||||||
|
|
||||||
|
// Assemble main UI
|
||||||
|
main_container.append(chat_view.widget());
|
||||||
|
main_container.append(&input_area.container);
|
||||||
|
main_container.append(&controls_area.container);
|
||||||
|
|
||||||
|
window.set_child(Some(&main_container));
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
handlers::setup_handlers(
|
||||||
|
shared_state,
|
||||||
|
chat_view,
|
||||||
|
input_area,
|
||||||
|
controls_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_css(window: &ApplicationWindow, config: &Config) {
|
||||||
|
let css_provider = gtk4::CssProvider::new();
|
||||||
|
|
||||||
|
// Generate CSS from config
|
||||||
|
let css_content = generate_css_from_config(config);
|
||||||
|
css_provider.load_from_string(&css_content);
|
||||||
|
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
>k4::prelude::WidgetExt::display(window),
|
||||||
|
&css_provider,
|
||||||
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_css_from_config(config: &Config) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
window {{
|
||||||
|
font-size: {}px;
|
||||||
|
background-color: {};
|
||||||
|
}}
|
||||||
|
|
||||||
|
.input-container, .input-text, .input-text > * {{
|
||||||
|
background-color: {};
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.input-text {{
|
||||||
|
font-size: {}px;
|
||||||
|
margin-left: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 60px;
|
||||||
|
color: {};
|
||||||
|
}}
|
||||||
|
|
||||||
|
.chat-container, .chat-text, .chat-text > * {{
|
||||||
|
background-color: {};
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.chat-text {{
|
||||||
|
font-size: {}px;
|
||||||
|
padding: 24px;
|
||||||
|
color: {};
|
||||||
|
}}
|
||||||
|
|
||||||
|
.input-text:focus {{
|
||||||
|
border-color: {};
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
button {{
|
||||||
|
font-size: {}px;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.stop-button {{
|
||||||
|
background-color: {};
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.send-button {{
|
||||||
|
background-color: {};
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
|
||||||
|
dropdown {{
|
||||||
|
font-size: {}px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
checkbutton {{
|
||||||
|
font-size: {}px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.status-label {{
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
config.ui.window_font_size, // window font-size
|
||||||
|
config.colors.window_background, // window background
|
||||||
|
config.colors.chat_background, // chat background
|
||||||
|
config.ui.chat_font_size, // chat font-size
|
||||||
|
config.colors.primary_text, // chat color
|
||||||
|
config.colors.chat_background, // input background (reuse chat)
|
||||||
|
config.ui.input_font_size, // input font-size
|
||||||
|
config.colors.primary_text, // input color
|
||||||
|
config.colors.link_text, // input focus border
|
||||||
|
config.ui.window_font_size, // button font-size
|
||||||
|
config.colors.stop_button, // stop button background
|
||||||
|
config.colors.send_button, // send button background
|
||||||
|
config.ui.window_font_size, // dropdown font-size
|
||||||
|
config.ui.window_font_size, // checkbutton font-size
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/config.rs
Normal file
131
src/config.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub ui: UiConfig,
|
||||||
|
pub colors: ColorConfig,
|
||||||
|
pub ollama: OllamaConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UiConfig {
|
||||||
|
pub window_font_size: u32,
|
||||||
|
pub chat_font_size: u32,
|
||||||
|
pub input_font_size: u32,
|
||||||
|
pub code_font_family: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ColorConfig {
|
||||||
|
// Background colors
|
||||||
|
pub chat_background: String,
|
||||||
|
pub code_background: String,
|
||||||
|
pub window_background: String,
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
pub primary_text: String,
|
||||||
|
pub code_text: String,
|
||||||
|
pub link_text: String,
|
||||||
|
pub think_text: String,
|
||||||
|
|
||||||
|
// Button colors
|
||||||
|
pub send_button: String,
|
||||||
|
pub stop_button: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OllamaConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ui: UiConfig::default(),
|
||||||
|
colors: ColorConfig::default(),
|
||||||
|
ollama: OllamaConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
window_font_size: 16,
|
||||||
|
chat_font_size: 18,
|
||||||
|
input_font_size: 16,
|
||||||
|
code_font_family: "monospace".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ColorConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Background colors
|
||||||
|
chat_background: "#ffffff".to_string(),
|
||||||
|
code_background: "#f5f5f5".to_string(),
|
||||||
|
window_background: "#fafafa".to_string(),
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
primary_text: "#333333".to_string(),
|
||||||
|
code_text: "#d63384".to_string(),
|
||||||
|
link_text: "#0066cc".to_string(),
|
||||||
|
think_text: "#6666cc".to_string(),
|
||||||
|
|
||||||
|
// Button colors
|
||||||
|
send_button: "#007bff".to_string(),
|
||||||
|
stop_button: "#dc3545".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OllamaConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
url: "http://localhost:11434".to_string(),
|
||||||
|
timeout_seconds: 120,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let config_path = Self::get_config_path()?;
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
let content = fs::read_to_string(&config_path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
// Create default config file
|
||||||
|
let default_config = Config::default();
|
||||||
|
default_config.save()?;
|
||||||
|
Ok(default_config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config_path = Self::get_config_path()?;
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&config_path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
let config_dir = dirs::config_dir()
|
||||||
|
.ok_or("Could not determine config directory")?
|
||||||
|
.join("ollama-chat");
|
||||||
|
|
||||||
|
Ok(config_dir.join("config.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -1,16 +1,18 @@
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{glib, Application};
|
use gtk4::{glib, Application};
|
||||||
|
|
||||||
mod ui;
|
mod app;
|
||||||
|
mod state;
|
||||||
mod api;
|
mod api;
|
||||||
mod types;
|
mod types;
|
||||||
mod state;
|
mod markdown_renderer;
|
||||||
mod markdown_processor;
|
mod ui;
|
||||||
|
mod config;
|
||||||
|
|
||||||
const APP_ID: &str = "com.example.ollama-chat";
|
const APP_ID: &str = "com.example.ollama-chat";
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
let app = Application::builder().application_id(APP_ID).build();
|
let app = Application::builder().application_id(APP_ID).build();
|
||||||
app.connect_activate(ui::build_ui);
|
app.connect_activate(app::build_ui);
|
||||||
app.run()
|
app.run()
|
||||||
}
|
}
|
||||||
|
|
@ -1,351 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{TextBuffer, TextTag, TextTagTable};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
pub struct MarkdownProcessor {
|
|
||||||
h1_tag: TextTag,
|
|
||||||
h2_tag: TextTag,
|
|
||||||
h3_tag: TextTag,
|
|
||||||
h4_tag: TextTag,
|
|
||||||
h5_tag: TextTag,
|
|
||||||
h6_tag: TextTag,
|
|
||||||
bold_tag: TextTag,
|
|
||||||
italic_tag: TextTag,
|
|
||||||
code_tag: TextTag,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MarkdownProcessor {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
// Heading tags
|
|
||||||
let h1_tag = TextTag::new(Some("h1"));
|
|
||||||
h1_tag.set_weight(700);
|
|
||||||
h1_tag.set_line_height(1.4);
|
|
||||||
h1_tag.set_scale(1.5);
|
|
||||||
|
|
||||||
let h2_tag = TextTag::new(Some("h2"));
|
|
||||||
h2_tag.set_weight(700);
|
|
||||||
h2_tag.set_line_height(1.4);
|
|
||||||
h2_tag.set_scale(1.4);
|
|
||||||
|
|
||||||
let h3_tag = TextTag::new(Some("h3"));
|
|
||||||
h3_tag.set_weight(700);
|
|
||||||
h3_tag.set_line_height(1.2);
|
|
||||||
h3_tag.set_scale(1.3);
|
|
||||||
|
|
||||||
let h4_tag = TextTag::new(Some("h4"));
|
|
||||||
h4_tag.set_weight(700);
|
|
||||||
h4_tag.set_line_height(1.2);
|
|
||||||
h4_tag.set_scale(1.2);
|
|
||||||
|
|
||||||
let h5_tag = TextTag::new(Some("h5"));
|
|
||||||
h5_tag.set_weight(700);
|
|
||||||
h5_tag.set_line_height(1.2);
|
|
||||||
h5_tag.set_scale(1.1);
|
|
||||||
|
|
||||||
let h6_tag = TextTag::new(Some("h6"));
|
|
||||||
h6_tag.set_weight(700);
|
|
||||||
h6_tag.set_line_height(1.2);
|
|
||||||
h6_tag.set_scale(1.0);
|
|
||||||
|
|
||||||
// Inline formatting tags
|
|
||||||
let bold_tag = TextTag::new(Some("bold"));
|
|
||||||
bold_tag.set_weight(700);
|
|
||||||
|
|
||||||
let italic_tag = TextTag::new(Some("italic"));
|
|
||||||
italic_tag.set_style(gtk4::pango::Style::Italic);
|
|
||||||
|
|
||||||
let code_tag = TextTag::new(Some("code"));
|
|
||||||
code_tag.set_property("font", &"monospace");
|
|
||||||
code_tag.set_scale(0.9);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
h1_tag,
|
|
||||||
h2_tag,
|
|
||||||
h3_tag,
|
|
||||||
h4_tag,
|
|
||||||
h5_tag,
|
|
||||||
h6_tag,
|
|
||||||
bold_tag,
|
|
||||||
italic_tag,
|
|
||||||
code_tag,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn setup_tags(&self, buffer: &TextBuffer) {
|
|
||||||
let tag_table = buffer.tag_table();
|
|
||||||
tag_table.add(&self.h1_tag);
|
|
||||||
tag_table.add(&self.h2_tag);
|
|
||||||
tag_table.add(&self.h3_tag);
|
|
||||||
tag_table.add(&self.h4_tag);
|
|
||||||
tag_table.add(&self.h5_tag);
|
|
||||||
tag_table.add(&self.h6_tag);
|
|
||||||
tag_table.add(&self.bold_tag);
|
|
||||||
tag_table.add(&self.italic_tag);
|
|
||||||
tag_table.add(&self.code_tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_formatted_text(&self, buffer: &TextBuffer, text: &str, iter: &mut gtk4::TextIter) {
|
|
||||||
// Process the text line by line to handle headings and inline formatting
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
// Check if this line is a heading
|
|
||||||
if let Some(heading) = self.parse_heading(line) {
|
|
||||||
let tag = match heading.level {
|
|
||||||
1 => &self.h1_tag,
|
|
||||||
2 => &self.h2_tag,
|
|
||||||
3 => &self.h3_tag,
|
|
||||||
4 => &self.h4_tag,
|
|
||||||
5 => &self.h5_tag,
|
|
||||||
_ => &self.h6_tag,
|
|
||||||
};
|
|
||||||
|
|
||||||
buffer.insert(iter, "\n");
|
|
||||||
buffer.insert_with_tags(iter, &heading.content, &[tag]);
|
|
||||||
buffer.insert(iter, "\n");
|
|
||||||
} else {
|
|
||||||
// Process inline formatting for non-heading lines
|
|
||||||
self.insert_formatted_line(buffer, line, iter);
|
|
||||||
|
|
||||||
// Add newline if not the last line
|
|
||||||
if i < lines.len() - 1 {
|
|
||||||
buffer.insert(iter, "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_formatted_line(&self, buffer: &TextBuffer, line: &str, iter: &mut gtk4::TextIter) {
|
|
||||||
let segments = self.parse_inline_formatting(line);
|
|
||||||
|
|
||||||
for segment in segments {
|
|
||||||
match segment.format_type {
|
|
||||||
FormatType::Plain => {
|
|
||||||
buffer.insert(iter, &segment.content);
|
|
||||||
}
|
|
||||||
FormatType::Bold => {
|
|
||||||
buffer.insert_with_tags(iter, &segment.content, &[&self.bold_tag]);
|
|
||||||
}
|
|
||||||
FormatType::Italic => {
|
|
||||||
buffer.insert_with_tags(iter, &segment.content, &[&self.italic_tag]);
|
|
||||||
}
|
|
||||||
FormatType::Code => {
|
|
||||||
buffer.insert_with_tags(iter, &segment.content, &[&self.code_tag]);
|
|
||||||
}
|
|
||||||
FormatType::BoldItalic => {
|
|
||||||
buffer.insert_with_tags(iter, &segment.content, &[&self.bold_tag, &self.italic_tag]);
|
|
||||||
}
|
|
||||||
FormatType::Heading(_) => {
|
|
||||||
// This shouldn't happen in inline processing
|
|
||||||
buffer.insert(iter, &segment.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_heading(&self, line: &str) -> Option<HeadingInfo> {
|
|
||||||
if line.starts_with('#') {
|
|
||||||
let hash_count = line.chars().take_while(|&c| c == '#').count();
|
|
||||||
|
|
||||||
if hash_count <= 6 && line.len() > hash_count + 1 && line.chars().nth(hash_count) == Some(' ') {
|
|
||||||
let content = &line[hash_count + 1..].trim();
|
|
||||||
return Some(HeadingInfo {
|
|
||||||
level: hash_count,
|
|
||||||
content: content.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_inline_formatting(&self, text: &str) -> Vec<TextSegment> {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut current_pos = 0;
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
|
|
||||||
while current_pos < chars.len() {
|
|
||||||
// Look for the next formatting marker
|
|
||||||
if let Some((marker_pos, marker_type, marker_len)) = self.find_next_marker(&chars, current_pos) {
|
|
||||||
// Add any plain text before the marker
|
|
||||||
if marker_pos > current_pos {
|
|
||||||
let plain_text: String = chars[current_pos..marker_pos].iter().collect();
|
|
||||||
if !plain_text.is_empty() {
|
|
||||||
segments.push(TextSegment {
|
|
||||||
content: plain_text,
|
|
||||||
format_type: FormatType::Plain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the closing marker
|
|
||||||
if let Some((close_pos, close_len)) = self.find_closing_marker(&chars, marker_pos + marker_len, &marker_type) {
|
|
||||||
let content_start = marker_pos + marker_len;
|
|
||||||
let content_end = close_pos;
|
|
||||||
|
|
||||||
if content_start < content_end {
|
|
||||||
let content: String = chars[content_start..content_end].iter().collect();
|
|
||||||
segments.push(TextSegment {
|
|
||||||
content,
|
|
||||||
format_type: marker_type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
current_pos = close_pos + close_len;
|
|
||||||
} else {
|
|
||||||
// No closing marker found, treat as plain text
|
|
||||||
let plain_char: String = chars[marker_pos..marker_pos + marker_len].iter().collect();
|
|
||||||
segments.push(TextSegment {
|
|
||||||
content: plain_char,
|
|
||||||
format_type: FormatType::Plain,
|
|
||||||
});
|
|
||||||
current_pos = marker_pos + marker_len;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No more markers, add the rest as plain text
|
|
||||||
let remaining: String = chars[current_pos..].iter().collect();
|
|
||||||
if !remaining.is_empty() {
|
|
||||||
segments.push(TextSegment {
|
|
||||||
content: remaining,
|
|
||||||
format_type: FormatType::Plain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segments
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_next_marker(&self, chars: &[char], start_pos: usize) -> Option<(usize, FormatType, usize)> {
|
|
||||||
let mut earliest_pos = None;
|
|
||||||
let mut earliest_type = FormatType::Plain;
|
|
||||||
let mut earliest_len = 0;
|
|
||||||
|
|
||||||
for pos in start_pos..chars.len() {
|
|
||||||
// Check for inline code (backticks)
|
|
||||||
if chars[pos] == '`' {
|
|
||||||
if earliest_pos.is_none() || pos < earliest_pos.unwrap() {
|
|
||||||
earliest_pos = Some(pos);
|
|
||||||
earliest_type = FormatType::Code;
|
|
||||||
earliest_len = 1;
|
|
||||||
}
|
|
||||||
break; // Prioritize code as it can contain other markers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bold/italic markers
|
|
||||||
if pos + 1 < chars.len() {
|
|
||||||
// Check for ** (bold) or *** (bold+italic)
|
|
||||||
if chars[pos] == '*' && chars[pos + 1] == '*' {
|
|
||||||
if pos + 2 < chars.len() && chars[pos + 2] == '*' {
|
|
||||||
// *** bold+italic
|
|
||||||
if earliest_pos.is_none() || pos < earliest_pos.unwrap() {
|
|
||||||
earliest_pos = Some(pos);
|
|
||||||
earliest_type = FormatType::BoldItalic;
|
|
||||||
earliest_len = 3;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ** bold
|
|
||||||
if earliest_pos.is_none() || pos < earliest_pos.unwrap() {
|
|
||||||
earliest_pos = Some(pos);
|
|
||||||
earliest_type = FormatType::Bold;
|
|
||||||
earliest_len = 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for __ (bold)
|
|
||||||
if chars[pos] == '_' && chars[pos + 1] == '_' {
|
|
||||||
if earliest_pos.is_none() || pos < earliest_pos.unwrap() {
|
|
||||||
earliest_pos = Some(pos);
|
|
||||||
earliest_type = FormatType::Bold;
|
|
||||||
earliest_len = 2;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for single * or _ (italic)
|
|
||||||
if chars[pos] == '*' || chars[pos] == '_' {
|
|
||||||
// Make sure it's not part of a double marker
|
|
||||||
let is_single = (pos == 0 || chars[pos - 1] != chars[pos]) &&
|
|
||||||
(pos + 1 >= chars.len() || chars[pos + 1] != chars[pos]);
|
|
||||||
|
|
||||||
if is_single && (earliest_pos.is_none() || pos < earliest_pos.unwrap()) {
|
|
||||||
earliest_pos = Some(pos);
|
|
||||||
earliest_type = FormatType::Italic;
|
|
||||||
earliest_len = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
earliest_pos.map(|pos| (pos, earliest_type, earliest_len))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_closing_marker(&self, chars: &[char], start_pos: usize, marker_type: &FormatType) -> Option<(usize, usize)> {
|
|
||||||
match marker_type {
|
|
||||||
FormatType::Code => {
|
|
||||||
// Look for closing backtick
|
|
||||||
for pos in start_pos..chars.len() {
|
|
||||||
if chars[pos] == '`' {
|
|
||||||
return Some((pos, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FormatType::Bold => {
|
|
||||||
// Look for closing ** or __
|
|
||||||
for pos in start_pos..chars.len().saturating_sub(1) {
|
|
||||||
if (chars[pos] == '*' && chars[pos + 1] == '*') ||
|
|
||||||
(chars[pos] == '_' && chars[pos + 1] == '_') {
|
|
||||||
return Some((pos, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FormatType::Italic => {
|
|
||||||
// Look for closing * or _
|
|
||||||
for pos in start_pos..chars.len() {
|
|
||||||
if chars[pos] == '*' || chars[pos] == '_' {
|
|
||||||
// Make sure it's not part of a double marker
|
|
||||||
let is_single = (pos == 0 || chars[pos - 1] != chars[pos]) &&
|
|
||||||
(pos + 1 >= chars.len() || chars[pos + 1] != chars[pos]);
|
|
||||||
if is_single {
|
|
||||||
return Some((pos, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FormatType::BoldItalic => {
|
|
||||||
// Look for closing ***
|
|
||||||
for pos in start_pos..chars.len().saturating_sub(2) {
|
|
||||||
if chars[pos] == '*' && chars[pos + 1] == '*' && chars[pos + 2] == '*' {
|
|
||||||
return Some((pos, 3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct TextSegment {
|
|
||||||
content: String,
|
|
||||||
format_type: FormatType,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct HeadingInfo {
|
|
||||||
level: usize,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
enum FormatType {
|
|
||||||
Plain,
|
|
||||||
Heading(usize),
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Code,
|
|
||||||
BoldItalic,
|
|
||||||
}
|
|
||||||
402
src/markdown_renderer.rs
Normal file
402
src/markdown_renderer.rs
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{TextBuffer, TextTag, TextIter};
|
||||||
|
use pulldown_cmark::{Parser, Event, Tag, TagEnd, HeadingLevel, Options};
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub struct MarkdownRenderer {
|
||||||
|
// Text formatting tags
|
||||||
|
h1_tag: TextTag,
|
||||||
|
h2_tag: TextTag,
|
||||||
|
h3_tag: TextTag,
|
||||||
|
h4_tag: TextTag,
|
||||||
|
h5_tag: TextTag,
|
||||||
|
h6_tag: TextTag,
|
||||||
|
bold_tag: TextTag,
|
||||||
|
italic_tag: TextTag,
|
||||||
|
code_tag: TextTag,
|
||||||
|
code_block_tag: TextTag,
|
||||||
|
link_tag: TextTag,
|
||||||
|
quote_tag: TextTag,
|
||||||
|
think_tag: TextTag,
|
||||||
|
|
||||||
|
// State for nested formatting
|
||||||
|
format_stack: Vec<TextTag>,
|
||||||
|
// Track if tags are already setup
|
||||||
|
tags_setup: bool,
|
||||||
|
// State for streaming think tag processing
|
||||||
|
in_think_tag: bool,
|
||||||
|
think_buffer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownRenderer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
h1_tag: TextTag::new(Some("h1")),
|
||||||
|
h2_tag: TextTag::new(Some("h2")),
|
||||||
|
h3_tag: TextTag::new(Some("h3")),
|
||||||
|
h4_tag: TextTag::new(Some("h4")),
|
||||||
|
h5_tag: TextTag::new(Some("h5")),
|
||||||
|
h6_tag: TextTag::new(Some("h6")),
|
||||||
|
bold_tag: TextTag::new(Some("bold")),
|
||||||
|
italic_tag: TextTag::new(Some("italic")),
|
||||||
|
code_tag: TextTag::new(Some("code")),
|
||||||
|
code_block_tag: TextTag::new(Some("code_block")),
|
||||||
|
link_tag: TextTag::new(Some("link")),
|
||||||
|
quote_tag: TextTag::new(Some("quote")),
|
||||||
|
think_tag: TextTag::new(Some("think")),
|
||||||
|
format_stack: Vec::new(),
|
||||||
|
tags_setup: false,
|
||||||
|
in_think_tag: false,
|
||||||
|
think_buffer: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_tags(&mut self, buffer: &TextBuffer, config: &Config) {
|
||||||
|
if self.tags_setup {
|
||||||
|
return; // Tags already setup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure heading tags
|
||||||
|
self.h1_tag.set_weight(700);
|
||||||
|
self.h1_tag.set_scale(2.0);
|
||||||
|
self.h1_tag.set_property("pixels-above-lines", 12);
|
||||||
|
self.h1_tag.set_property("pixels-below-lines", 6);
|
||||||
|
|
||||||
|
self.h2_tag.set_weight(700);
|
||||||
|
self.h2_tag.set_scale(1.5);
|
||||||
|
self.h2_tag.set_property("pixels-above-lines", 10);
|
||||||
|
self.h2_tag.set_property("pixels-below-lines", 5);
|
||||||
|
|
||||||
|
self.h3_tag.set_weight(700);
|
||||||
|
self.h3_tag.set_scale(1.3);
|
||||||
|
self.h3_tag.set_property("pixels-above-lines", 8);
|
||||||
|
self.h3_tag.set_property("pixels-below-lines", 4);
|
||||||
|
|
||||||
|
self.h4_tag.set_weight(700);
|
||||||
|
self.h4_tag.set_scale(1.1);
|
||||||
|
self.h4_tag.set_property("pixels-above-lines", 6);
|
||||||
|
self.h4_tag.set_property("pixels-below-lines", 3);
|
||||||
|
|
||||||
|
self.h5_tag.set_weight(700);
|
||||||
|
self.h5_tag.set_scale(1.0);
|
||||||
|
self.h5_tag.set_property("pixels-above-lines", 4);
|
||||||
|
self.h5_tag.set_property("pixels-below-lines", 2);
|
||||||
|
|
||||||
|
self.h6_tag.set_weight(600);
|
||||||
|
self.h6_tag.set_scale(0.9);
|
||||||
|
self.h6_tag.set_property("pixels-above-lines", 3);
|
||||||
|
self.h6_tag.set_property("pixels-below-lines", 2);
|
||||||
|
|
||||||
|
// Configure text formatting tags
|
||||||
|
self.bold_tag.set_weight(700);
|
||||||
|
self.italic_tag.set_style(gtk4::pango::Style::Italic);
|
||||||
|
|
||||||
|
// Configure code tags with config colors
|
||||||
|
self.code_tag.set_family(Some(&config.ui.code_font_family));
|
||||||
|
if let Ok(bg_color) = parse_color(&config.colors.code_background) {
|
||||||
|
self.code_tag.set_background_rgba(Some(&bg_color));
|
||||||
|
}
|
||||||
|
if let Ok(fg_color) = parse_color(&config.colors.code_text) {
|
||||||
|
self.code_tag.set_foreground_rgba(Some(&fg_color));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.code_block_tag.set_family(Some(&config.ui.code_font_family));
|
||||||
|
if let Ok(bg_color) = parse_color(&config.colors.code_background) {
|
||||||
|
self.code_block_tag.set_background_rgba(Some(&bg_color));
|
||||||
|
}
|
||||||
|
if let Ok(fg_color) = parse_color(&config.colors.code_text) {
|
||||||
|
self.code_block_tag.set_foreground_rgba(Some(&fg_color));
|
||||||
|
}
|
||||||
|
self.code_block_tag.set_property("left-margin", 20);
|
||||||
|
self.code_block_tag.set_property("right-margin", 20);
|
||||||
|
self.code_block_tag.set_property("pixels-above-lines", 8);
|
||||||
|
self.code_block_tag.set_property("pixels-below-lines", 8);
|
||||||
|
|
||||||
|
// Configure link tag with config color
|
||||||
|
if let Ok(link_color) = parse_color(&config.colors.link_text) {
|
||||||
|
self.link_tag.set_foreground_rgba(Some(&link_color));
|
||||||
|
}
|
||||||
|
self.link_tag.set_underline(gtk4::pango::Underline::Single);
|
||||||
|
|
||||||
|
// Configure quote tag
|
||||||
|
self.quote_tag.set_foreground_rgba(Some(>k4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0)));
|
||||||
|
self.quote_tag.set_style(gtk4::pango::Style::Italic);
|
||||||
|
self.quote_tag.set_property("left-margin", 20);
|
||||||
|
self.quote_tag.set_property("pixels-above-lines", 4);
|
||||||
|
self.quote_tag.set_property("pixels-below-lines", 4);
|
||||||
|
|
||||||
|
// Configure think tag with config color
|
||||||
|
if let Ok(think_color) = parse_color(&config.colors.think_text) {
|
||||||
|
self.think_tag.set_foreground_rgba(Some(&think_color));
|
||||||
|
}
|
||||||
|
self.think_tag.set_style(gtk4::pango::Style::Italic);
|
||||||
|
self.think_tag.set_scale(0.9);
|
||||||
|
self.think_tag.set_property("left-margin", 15);
|
||||||
|
self.think_tag.set_property("right-margin", 15);
|
||||||
|
self.think_tag.set_property("pixels-above-lines", 6);
|
||||||
|
self.think_tag.set_property("pixels-below-lines", 6);
|
||||||
|
|
||||||
|
// Add tags to buffer
|
||||||
|
let tag_table = buffer.tag_table();
|
||||||
|
let tags = [
|
||||||
|
&self.h1_tag, &self.h2_tag, &self.h3_tag,
|
||||||
|
&self.h4_tag, &self.h5_tag, &self.h6_tag,
|
||||||
|
&self.bold_tag, &self.italic_tag, &self.code_tag,
|
||||||
|
&self.code_block_tag, &self.link_tag, &self.quote_tag,
|
||||||
|
&self.think_tag,
|
||||||
|
];
|
||||||
|
|
||||||
|
for tag in tags {
|
||||||
|
if let Some(tag_name) = tag.name() {
|
||||||
|
if tag_table.lookup(&tag_name).is_none() {
|
||||||
|
tag_table.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tags_setup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Render markdown starting at the given iterator position without clearing the buffer
|
||||||
|
pub fn render_markdown_at_iter(&mut self, buffer: &TextBuffer, markdown_text: &str, iter: &mut TextIter, config: &Config) {
|
||||||
|
// Ensure tags are setup with current config
|
||||||
|
self.setup_tags(buffer, config);
|
||||||
|
|
||||||
|
// Process text for think tags during streaming
|
||||||
|
let processed_text = self.process_streaming_text(buffer, markdown_text, iter);
|
||||||
|
|
||||||
|
if !processed_text.is_empty() {
|
||||||
|
// Configure pulldown-cmark options
|
||||||
|
let mut options = Options::empty();
|
||||||
|
options.insert(Options::ENABLE_TABLES);
|
||||||
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
options.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
options.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(&processed_text, options);
|
||||||
|
|
||||||
|
for event in parser {
|
||||||
|
self.process_event(buffer, iter, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process text for streaming, handling think tags in real-time
|
||||||
|
fn process_streaming_text(&mut self, buffer: &TextBuffer, text: &str, iter: &mut TextIter) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut remaining = text;
|
||||||
|
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
if self.in_think_tag {
|
||||||
|
// We're currently inside a think tag, look for closing tag
|
||||||
|
if let Some(end_pos) = remaining.find("</think>") {
|
||||||
|
// Found closing tag - stream the remaining think content
|
||||||
|
let final_think_content = &remaining[..end_pos];
|
||||||
|
if !final_think_content.is_empty() {
|
||||||
|
buffer.insert_with_tags(iter, final_think_content, &[&self.think_tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the think section
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
|
||||||
|
// Reset think state
|
||||||
|
self.in_think_tag = false;
|
||||||
|
self.think_buffer.clear();
|
||||||
|
|
||||||
|
// Continue with text after closing tag
|
||||||
|
remaining = &remaining[end_pos + 8..]; // 8 = "</think>".len()
|
||||||
|
} else {
|
||||||
|
// No closing tag yet, stream the think content as it arrives
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
buffer.insert_with_tags(iter, remaining, &[&self.think_tag]);
|
||||||
|
}
|
||||||
|
break; // Wait for more streaming content
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not in think tag, look for opening tag
|
||||||
|
if let Some(start_pos) = remaining.find("<think>") {
|
||||||
|
// Add content before think tag to result for normal processing
|
||||||
|
result.push_str(&remaining[..start_pos]);
|
||||||
|
|
||||||
|
// Start think mode and show the think indicator
|
||||||
|
self.in_think_tag = true;
|
||||||
|
self.think_buffer.clear();
|
||||||
|
buffer.insert(iter, "\n💭 ");
|
||||||
|
|
||||||
|
// Continue with content after opening tag
|
||||||
|
remaining = &remaining[start_pos + 7..]; // 7 = "<think>".len()
|
||||||
|
} else {
|
||||||
|
// No think tag found, add all remaining text to result
|
||||||
|
result.push_str(remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_event(&mut self, buffer: &TextBuffer, iter: &mut TextIter, event: Event) {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => {
|
||||||
|
self.handle_start_tag(buffer, iter, tag);
|
||||||
|
}
|
||||||
|
Event::End(tag_end) => {
|
||||||
|
self.handle_end_tag(buffer, iter, tag_end);
|
||||||
|
}
|
||||||
|
Event::Text(text) => {
|
||||||
|
self.insert_text(buffer, iter, &text);
|
||||||
|
}
|
||||||
|
Event::Code(code) => {
|
||||||
|
let active_tags: Vec<&TextTag> = self.format_stack.iter().collect();
|
||||||
|
let mut all_tags = vec![&self.code_tag];
|
||||||
|
all_tags.extend(active_tags);
|
||||||
|
buffer.insert_with_tags(iter, &code, &all_tags);
|
||||||
|
}
|
||||||
|
Event::Html(html) => {
|
||||||
|
// Skip HTML for security - or you could sanitize it
|
||||||
|
buffer.insert(iter, &format!("[HTML: {}]", html));
|
||||||
|
}
|
||||||
|
Event::FootnoteReference(name) => {
|
||||||
|
buffer.insert(iter, &format!("[^{}]", name));
|
||||||
|
}
|
||||||
|
Event::SoftBreak => {
|
||||||
|
buffer.insert(iter, " ");
|
||||||
|
}
|
||||||
|
Event::HardBreak => {
|
||||||
|
buffer.insert(iter, "\n");
|
||||||
|
}
|
||||||
|
Event::Rule => {
|
||||||
|
buffer.insert(iter, "\n────────────────────────────────────────\n");
|
||||||
|
}
|
||||||
|
Event::TaskListMarker(checked) => {
|
||||||
|
let marker = if checked { "☑ " } else { "☐ " };
|
||||||
|
buffer.insert(iter, marker);
|
||||||
|
}
|
||||||
|
Event::InlineMath(math) => {
|
||||||
|
// For inline math, you might want to render it differently
|
||||||
|
buffer.insert(iter, &format!("${}$", math));
|
||||||
|
}
|
||||||
|
Event::DisplayMath(math) => {
|
||||||
|
// For display math, typically rendered in its own line
|
||||||
|
buffer.insert(iter, &format!("\n$$\n{}\n$$\n", math));
|
||||||
|
}
|
||||||
|
Event::InlineHtml(html) => {
|
||||||
|
// Similar to HTML handling, you might want to skip or sanitize
|
||||||
|
buffer.insert(iter, &format!("[InlineHTML: {}]", html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_start_tag(&mut self, buffer: &TextBuffer, iter: &mut TextIter, tag: Tag) {
|
||||||
|
let format_tag = match tag {
|
||||||
|
Tag::Heading { level, .. } => {
|
||||||
|
// Add some spacing before headings if not at start
|
||||||
|
if iter.offset() > 0 {
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
}
|
||||||
|
match level {
|
||||||
|
HeadingLevel::H1 => Some(&self.h1_tag),
|
||||||
|
HeadingLevel::H2 => Some(&self.h2_tag),
|
||||||
|
HeadingLevel::H3 => Some(&self.h3_tag),
|
||||||
|
HeadingLevel::H4 => Some(&self.h4_tag),
|
||||||
|
HeadingLevel::H5 => Some(&self.h5_tag),
|
||||||
|
HeadingLevel::H6 => Some(&self.h6_tag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tag::Paragraph => {
|
||||||
|
// Add spacing before paragraphs if not at start
|
||||||
|
if iter.offset() > 0 {
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Tag::Emphasis => Some(&self.italic_tag),
|
||||||
|
Tag::Strong => Some(&self.bold_tag),
|
||||||
|
Tag::Link { .. } => Some(&self.link_tag),
|
||||||
|
Tag::CodeBlock(_) => {
|
||||||
|
if iter.offset() > 0 {
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
}
|
||||||
|
Some(&self.code_block_tag)
|
||||||
|
}
|
||||||
|
Tag::BlockQuote(_) => {
|
||||||
|
if iter.offset() > 0 {
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
}
|
||||||
|
Some(&self.quote_tag)
|
||||||
|
}
|
||||||
|
Tag::List(_) => {
|
||||||
|
if iter.offset() > 0 {
|
||||||
|
buffer.insert(iter, "\n\n");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
buffer.insert(iter, "• ");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tag_ref) = format_tag {
|
||||||
|
self.format_stack.push(tag_ref.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_end_tag(&mut self, buffer: &TextBuffer, iter: &mut TextIter, tag_end: TagEnd) {
|
||||||
|
match tag_end {
|
||||||
|
TagEnd::Heading(_) => {
|
||||||
|
buffer.insert(iter, "\n");
|
||||||
|
if !self.format_stack.is_empty() {
|
||||||
|
self.format_stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TagEnd::Paragraph => {
|
||||||
|
// Paragraph end handled by next element start
|
||||||
|
}
|
||||||
|
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
|
||||||
|
if !self.format_stack.is_empty() {
|
||||||
|
self.format_stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TagEnd::CodeBlock | TagEnd::BlockQuote(_) => {
|
||||||
|
buffer.insert(iter, "\n");
|
||||||
|
if !self.format_stack.is_empty() {
|
||||||
|
self.format_stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TagEnd::Item => {
|
||||||
|
buffer.insert(iter, "\n");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_text(&self, buffer: &TextBuffer, iter: &mut TextIter, text: &str) {
|
||||||
|
if self.format_stack.is_empty() {
|
||||||
|
buffer.insert(iter, text);
|
||||||
|
} else {
|
||||||
|
// Apply all active formatting tags
|
||||||
|
let tags: Vec<&TextTag> = self.format_stack.iter().collect();
|
||||||
|
buffer.insert_with_tags(iter, text, &tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to parse color strings (hex format) into RGBA
|
||||||
|
fn parse_color(color_str: &str) -> Result<gtk4::gdk::RGBA, Box<dyn std::error::Error>> {
|
||||||
|
let color_str = color_str.trim_start_matches('#');
|
||||||
|
|
||||||
|
if color_str.len() != 6 {
|
||||||
|
return Err("Color must be in #RRGGBB format".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = u8::from_str_radix(&color_str[0..2], 16)? as f32 / 255.0;
|
||||||
|
let g = u8::from_str_radix(&color_str[2..4], 16)? as f32 / 255.0;
|
||||||
|
let b = u8::from_str_radix(&color_str[4..6], 16)? as f32 / 255.0;
|
||||||
|
|
||||||
|
Ok(gtk4::gdk::RGBA::new(r, g, b, 1.0))
|
||||||
|
}
|
||||||
104
src/state.rs
104
src/state.rs
|
|
@ -1,21 +1,107 @@
|
||||||
use std::sync::{Arc, Mutex};
|
use std::rc::Rc;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use crate::types::ChatMessage;
|
use crate::types::ChatMessage;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub type SharedState = Rc<RefCell<AppState>>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
Api(String),
|
||||||
|
Ui(String),
|
||||||
|
State(String),
|
||||||
|
Validation(String),
|
||||||
|
Config(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AppError::Api(msg) => write!(f, "API Error: {}", msg),
|
||||||
|
AppError::Ui(msg) => write!(f, "UI Error: {}", msg),
|
||||||
|
AppError::State(msg) => write!(f, "State Error: {}", msg),
|
||||||
|
AppError::Validation(msg) => write!(f, "Validation Error: {}", msg),
|
||||||
|
AppError::Config(msg) => write!(f, "Config Error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AppError {}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum ButtonState {
|
||||||
|
Send,
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub conversation: Arc<Mutex<Vec<ChatMessage>>>,
|
pub conversation: Vec<ChatMessage>,
|
||||||
pub ollama_url: String,
|
pub ollama_url: String,
|
||||||
pub thinking_enabled: Arc<Mutex<bool>>,
|
pub is_generating: bool,
|
||||||
pub current_request_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
pub button_state: ButtonState,
|
||||||
|
pub current_task: Option<JoinHandle<()>>,
|
||||||
|
pub selected_model: Option<String>,
|
||||||
|
pub status_message: String,
|
||||||
|
pub config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let config = Config::load().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Warning: Failed to load config, using defaults: {}", e);
|
||||||
|
Config::default()
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
conversation: Arc::new(Mutex::new(Vec::new())),
|
conversation: Vec::new(),
|
||||||
ollama_url: "http://localhost:11434".to_string(),
|
ollama_url: config.ollama.url.clone(),
|
||||||
thinking_enabled: Arc::new(Mutex::new(false)),
|
is_generating: false,
|
||||||
current_request_handle: Arc::new(Mutex::new(None)),
|
button_state: ButtonState::Send,
|
||||||
|
current_task: None,
|
||||||
|
selected_model: None,
|
||||||
|
status_message: "Ready".to_string(),
|
||||||
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn set_generating(&mut self, generating: bool) {
|
||||||
|
self.is_generating = generating;
|
||||||
|
self.button_state = if generating {
|
||||||
|
ButtonState::Stop
|
||||||
|
} else {
|
||||||
|
ButtonState::Send
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_user_message(&mut self, content: String) {
|
||||||
|
self.conversation.push(ChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_assistant_message(&mut self, content: String) {
|
||||||
|
self.conversation.push(ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&mut self, message: String) {
|
||||||
|
self.status_message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abort_current_task(&mut self) {
|
||||||
|
if let Some(task) = self.current_task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
self.set_generating(false);
|
||||||
|
self.set_status("Generation stopped".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -11,8 +11,6 @@ pub struct ChatRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<ChatMessage>,
|
pub messages: Vec<ChatMessage>,
|
||||||
pub stream: bool,
|
pub stream: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub think: Option<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
572
src/ui.rs
572
src/ui.rs
|
|
@ -1,572 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{glib, Application, ApplicationWindow, Button, DropDown, Label, ScrolledWindow, TextView, TextBuffer, StringList, Orientation, PolicyType, WrapMode, Align, CheckButton};
|
|
||||||
use gtk4::Box as GtkBox;
|
|
||||||
use glib::{spawn_future_local, clone};
|
|
||||||
use std::sync::{OnceLock, Arc, Mutex};
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
use crate::api;
|
|
||||||
use crate::state::AppState;
|
|
||||||
use crate::types::ChatMessage;
|
|
||||||
use crate::markdown_processor::MarkdownProcessor;
|
|
||||||
|
|
||||||
// enum to track button state
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
enum ButtonState {
|
|
||||||
Send,
|
|
||||||
Stop,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_ui(app: &Application) {
|
|
||||||
let window = ApplicationWindow::builder()
|
|
||||||
.application(app)
|
|
||||||
.title("Ollama Chat")
|
|
||||||
.default_width(900)
|
|
||||||
.default_height(700)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let css_provider = gtk4::CssProvider::new();
|
|
||||||
css_provider.load_from_string(
|
|
||||||
r#"
|
|
||||||
window {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-text {
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-text {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-button {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdown {
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkbutton {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
gtk4::style_context_add_provider_for_display(
|
|
||||||
>k4::prelude::WidgetExt::display(&window),
|
|
||||||
&css_provider,
|
|
||||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Main container with padding
|
|
||||||
let main_container = GtkBox::new(Orientation::Vertical, 24);
|
|
||||||
main_container.set_margin_top(24);
|
|
||||||
main_container.set_margin_bottom(24);
|
|
||||||
main_container.set_margin_start(24);
|
|
||||||
main_container.set_margin_end(24);
|
|
||||||
|
|
||||||
// Chat display area
|
|
||||||
let chat_scroll = ScrolledWindow::new();
|
|
||||||
chat_scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
|
||||||
chat_scroll.set_vexpand(true);
|
|
||||||
|
|
||||||
let (chat_view, chat_buffer, markdown_processor) = create_chat_view();
|
|
||||||
chat_scroll.set_child(Some(&chat_view));
|
|
||||||
|
|
||||||
// Input area
|
|
||||||
let input_container = GtkBox::new(Orientation::Vertical, 16);
|
|
||||||
|
|
||||||
let input_area_container = GtkBox::new(Orientation::Horizontal, 16);
|
|
||||||
|
|
||||||
let input_scroll = ScrolledWindow::new();
|
|
||||||
input_scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
|
||||||
input_scroll.set_max_content_height(150);
|
|
||||||
input_scroll.set_propagate_natural_height(true);
|
|
||||||
input_scroll.set_hexpand(true);
|
|
||||||
|
|
||||||
let input_view = TextView::new();
|
|
||||||
input_view.add_css_class("input-text");
|
|
||||||
input_view.set_wrap_mode(WrapMode::WordChar);
|
|
||||||
input_view.set_accepts_tab(false);
|
|
||||||
let input_buffer = input_view.buffer();
|
|
||||||
input_scroll.set_child(Some(&input_view));
|
|
||||||
|
|
||||||
// Single button that changes between Send and Stop
|
|
||||||
let action_button = Button::with_label("Send");
|
|
||||||
action_button.add_css_class("send-button");
|
|
||||||
action_button.set_valign(Align::End);
|
|
||||||
|
|
||||||
// Track button state
|
|
||||||
let button_state = Arc::new(Mutex::new(ButtonState::Send));
|
|
||||||
|
|
||||||
input_area_container.append(&input_scroll);
|
|
||||||
input_area_container.append(&action_button);
|
|
||||||
|
|
||||||
// Bottom controls
|
|
||||||
let controls_container = GtkBox::new(Orientation::Horizontal, 16);
|
|
||||||
|
|
||||||
let model_label = Label::new(Some("Model:"));
|
|
||||||
|
|
||||||
// Create StringList to hold model names
|
|
||||||
let model_list = StringList::new(&[]);
|
|
||||||
let model_dropdown = DropDown::new(Some(model_list.clone()), None::<gtk4::Expression>);
|
|
||||||
|
|
||||||
// Add thinking checkbox
|
|
||||||
let thinking_checkbox = CheckButton::with_label("Think");
|
|
||||||
thinking_checkbox.set_tooltip_text(Some("Enable thinking mode for reasoning models (e.g., deepseek-r1)"));
|
|
||||||
|
|
||||||
let status_label = Label::new(Some("Ready"));
|
|
||||||
status_label.set_hexpand(true);
|
|
||||||
status_label.set_halign(Align::End);
|
|
||||||
|
|
||||||
controls_container.append(&model_label);
|
|
||||||
controls_container.append(&model_dropdown);
|
|
||||||
controls_container.append(&thinking_checkbox);
|
|
||||||
controls_container.append(&status_label);
|
|
||||||
|
|
||||||
input_container.append(&input_area_container);
|
|
||||||
input_container.append(&controls_container);
|
|
||||||
|
|
||||||
// Assemble main UI
|
|
||||||
main_container.append(&chat_scroll);
|
|
||||||
main_container.append(&input_container);
|
|
||||||
window.set_child(Some(&main_container));
|
|
||||||
|
|
||||||
// Initialize app state
|
|
||||||
let app_state = AppState::default();
|
|
||||||
|
|
||||||
// Load available models
|
|
||||||
load_models(model_list.clone(), model_dropdown.clone(), status_label.clone(), app_state.clone());
|
|
||||||
|
|
||||||
// Set up event handlers
|
|
||||||
setup_action_button_handler(
|
|
||||||
action_button.clone(),
|
|
||||||
button_state.clone(),
|
|
||||||
input_buffer,
|
|
||||||
chat_buffer.clone(),
|
|
||||||
model_dropdown,
|
|
||||||
model_list,
|
|
||||||
thinking_checkbox.clone(),
|
|
||||||
status_label.clone(),
|
|
||||||
app_state.clone(),
|
|
||||||
markdown_processor,
|
|
||||||
chat_scroll.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect thinking checkbox to app state
|
|
||||||
setup_thinking_checkbox_handler(thinking_checkbox, app_state.clone());
|
|
||||||
|
|
||||||
setup_keyboard_shortcut(input_view, action_button.clone(), button_state.clone());
|
|
||||||
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to update button appearance
|
|
||||||
fn update_button_state(button: &Button, state: ButtonState) {
|
|
||||||
match state {
|
|
||||||
ButtonState::Send => {
|
|
||||||
button.set_label("Send");
|
|
||||||
button.remove_css_class("stop-button");
|
|
||||||
button.add_css_class("send-button");
|
|
||||||
}
|
|
||||||
ButtonState::Stop => {
|
|
||||||
button.set_label("Stop");
|
|
||||||
button.remove_css_class("send-button");
|
|
||||||
button.add_css_class("stop-button");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_action_button_handler(
|
|
||||||
action_button: Button,
|
|
||||||
button_state: Arc<Mutex<ButtonState>>,
|
|
||||||
input_buffer: TextBuffer,
|
|
||||||
chat_buffer: TextBuffer,
|
|
||||||
model_dropdown: DropDown,
|
|
||||||
model_list: StringList,
|
|
||||||
thinking_checkbox: CheckButton,
|
|
||||||
status_label: Label,
|
|
||||||
app_state: AppState,
|
|
||||||
markdown_processor: Arc<MarkdownProcessor>,
|
|
||||||
chat_scroll: ScrolledWindow,
|
|
||||||
) {
|
|
||||||
action_button.connect_clicked(clone!(
|
|
||||||
#[weak] input_buffer,
|
|
||||||
#[weak] chat_buffer,
|
|
||||||
#[weak] model_dropdown,
|
|
||||||
#[weak] model_list,
|
|
||||||
#[weak] thinking_checkbox,
|
|
||||||
#[weak] status_label,
|
|
||||||
#[weak] action_button,
|
|
||||||
#[strong] button_state,
|
|
||||||
#[strong] markdown_processor,
|
|
||||||
move |_| {
|
|
||||||
let current_state = {
|
|
||||||
let state = button_state.lock().unwrap();
|
|
||||||
state.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
match current_state {
|
|
||||||
ButtonState::Send => {
|
|
||||||
// Handle send logic
|
|
||||||
let start_iter = input_buffer.start_iter();
|
|
||||||
let end_iter = input_buffer.end_iter();
|
|
||||||
let text = input_buffer.text(&start_iter, &end_iter, false);
|
|
||||||
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected model
|
|
||||||
let selected_idx = model_dropdown.selected();
|
|
||||||
if selected_idx == gtk4::INVALID_LIST_POSITION {
|
|
||||||
status_label.set_text("Please select a model first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let model = match model_list.string(selected_idx) {
|
|
||||||
Some(m) => m.to_string(),
|
|
||||||
None => {
|
|
||||||
status_label.set_text("Invalid model selection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get thinking checkbox state
|
|
||||||
let thinking_enabled = thinking_checkbox.is_active();
|
|
||||||
|
|
||||||
input_buffer.delete(&mut input_buffer.start_iter(), &mut input_buffer.end_iter());
|
|
||||||
|
|
||||||
// Change button to Stop state
|
|
||||||
{
|
|
||||||
let mut state = button_state.lock().unwrap();
|
|
||||||
*state = ButtonState::Stop;
|
|
||||||
}
|
|
||||||
update_button_state(&action_button, ButtonState::Stop);
|
|
||||||
|
|
||||||
send_message(
|
|
||||||
text.to_string(),
|
|
||||||
model,
|
|
||||||
thinking_enabled,
|
|
||||||
chat_buffer.clone(),
|
|
||||||
chat_scroll.clone(),
|
|
||||||
status_label.clone(),
|
|
||||||
action_button.clone(),
|
|
||||||
button_state.clone(),
|
|
||||||
app_state.clone(),
|
|
||||||
markdown_processor.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ButtonState::Stop => {
|
|
||||||
// Handle stop logic
|
|
||||||
let mut handle = app_state.current_request_handle.lock().unwrap();
|
|
||||||
if let Some(task) = handle.take() {
|
|
||||||
task.abort();
|
|
||||||
status_label.set_text("Generation stopped");
|
|
||||||
|
|
||||||
// Change button back to Send state
|
|
||||||
{
|
|
||||||
let mut state = button_state.lock().unwrap();
|
|
||||||
*state = ButtonState::Send;
|
|
||||||
}
|
|
||||||
update_button_state(&action_button, ButtonState::Send);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_keyboard_shortcut(input_view: TextView, action_button: Button, button_state: Arc<Mutex<ButtonState>>) {
|
|
||||||
let input_controller = gtk4::EventControllerKey::new();
|
|
||||||
input_controller.connect_key_pressed(clone!(
|
|
||||||
#[weak] action_button,
|
|
||||||
#[strong] button_state,
|
|
||||||
#[upgrade_or] glib::Propagation::Proceed,
|
|
||||||
move |_, key, _, modifier| {
|
|
||||||
if key == gtk4::gdk::Key::Return && modifier.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
|
||||||
// Only trigger if in Send state (don't allow Ctrl+Enter to stop)
|
|
||||||
let current_state = {
|
|
||||||
let state = button_state.lock().unwrap();
|
|
||||||
state.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
if current_state == ButtonState::Send {
|
|
||||||
action_button.emit_clicked();
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
|
||||||
} else {
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
input_view.add_controller(input_controller);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_message(
|
|
||||||
message: String,
|
|
||||||
model: String,
|
|
||||||
thinking_enabled: bool,
|
|
||||||
chat_buffer: TextBuffer,
|
|
||||||
chat_scroll: ScrolledWindow,
|
|
||||||
status_label: Label,
|
|
||||||
action_button: Button,
|
|
||||||
button_state: Arc<Mutex<ButtonState>>,
|
|
||||||
app_state: AppState,
|
|
||||||
markdown_processor: Arc<MarkdownProcessor>
|
|
||||||
) {
|
|
||||||
// Add user message to conversation
|
|
||||||
{
|
|
||||||
let mut conversation = app_state.conversation.lock().unwrap();
|
|
||||||
conversation.push(ChatMessage {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: message.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
append_to_chat(&chat_buffer, "You", &message, &markdown_processor);
|
|
||||||
|
|
||||||
// Add placeholder for assistant message
|
|
||||||
let mut end_iter = chat_buffer.end_iter();
|
|
||||||
let assistant_prefix = if thinking_enabled {
|
|
||||||
"\n\nAssistant (thinking enabled):\n"
|
|
||||||
} else {
|
|
||||||
"\n\nAssistant:\n"
|
|
||||||
};
|
|
||||||
chat_buffer.insert(&mut end_iter, assistant_prefix);
|
|
||||||
|
|
||||||
// Create mark where assistant response will be inserted
|
|
||||||
let assistant_start_mark = chat_buffer.create_mark(None, &chat_buffer.end_iter(), true);
|
|
||||||
|
|
||||||
let status_text = if thinking_enabled {
|
|
||||||
"Assistant is thinking..."
|
|
||||||
} else {
|
|
||||||
"Assistant is typing..."
|
|
||||||
};
|
|
||||||
status_label.set_text(status_text);
|
|
||||||
|
|
||||||
// Create channels for streaming communication
|
|
||||||
let (stream_sender, stream_receiver) = async_channel::bounded::<String>(50);
|
|
||||||
let (result_sender, result_receiver) = async_channel::bounded::<Result<String, Box<dyn std::error::Error + Send + Sync>>>(1);
|
|
||||||
|
|
||||||
// Spawn tokio task for API streaming
|
|
||||||
let app_state_clone = app_state.clone();
|
|
||||||
let model_clone = model.clone();
|
|
||||||
let task_handle = runtime().spawn(async move {
|
|
||||||
let result = api::send_chat_request_streaming(
|
|
||||||
&app_state_clone.ollama_url,
|
|
||||||
&model_clone,
|
|
||||||
&app_state_clone.conversation,
|
|
||||||
stream_sender,
|
|
||||||
thinking_enabled,
|
|
||||||
).await;
|
|
||||||
let _ = result_sender.send(result).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store the handle for potential cancellation
|
|
||||||
{
|
|
||||||
let mut handle = app_state.current_request_handle.lock().unwrap();
|
|
||||||
*handle = Some(task_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle streaming updates on the main loop
|
|
||||||
spawn_future_local(clone!(
|
|
||||||
#[weak] chat_buffer,
|
|
||||||
#[weak] assistant_start_mark,
|
|
||||||
#[weak] chat_scroll,
|
|
||||||
async move {
|
|
||||||
let mut accumulated_text = String::new();
|
|
||||||
|
|
||||||
while let Ok(token_batch) = stream_receiver.recv().await {
|
|
||||||
accumulated_text.push_str(&token_batch);
|
|
||||||
|
|
||||||
// Update UI with accumulated text (plain text during streaming)
|
|
||||||
let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
let mut end_iter = chat_buffer.end_iter();
|
|
||||||
|
|
||||||
// Replace content from mark to end
|
|
||||||
chat_buffer.delete(&mut start_iter, &mut end_iter);
|
|
||||||
let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
chat_buffer.insert(&mut insert_iter, &accumulated_text);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom after each update
|
|
||||||
let adjustment = chat_scroll.vadjustment();
|
|
||||||
adjustment.set_value(adjustment.upper() - adjustment.page_size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
// Handle final result - apply markdown formatting when streaming completes
|
|
||||||
let app_state_final = app_state.clone();
|
|
||||||
spawn_future_local(clone!(
|
|
||||||
#[weak] status_label,
|
|
||||||
#[weak] chat_buffer,
|
|
||||||
#[weak] assistant_start_mark,
|
|
||||||
#[weak] action_button,
|
|
||||||
#[strong] button_state,
|
|
||||||
#[strong] markdown_processor,
|
|
||||||
async move {
|
|
||||||
if let Ok(result) = result_receiver.recv().await {
|
|
||||||
match result {
|
|
||||||
Ok(response_text) => {
|
|
||||||
// Clear the plain streaming text
|
|
||||||
let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
let mut end_iter = chat_buffer.end_iter();
|
|
||||||
chat_buffer.delete(&mut start_iter, &mut end_iter);
|
|
||||||
|
|
||||||
// Insert formatted markdown text using the shared processor
|
|
||||||
let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
markdown_processor.insert_formatted_text(&chat_buffer, &response_text, &mut insert_iter);
|
|
||||||
|
|
||||||
// Add complete response to conversation
|
|
||||||
{
|
|
||||||
let mut conversation = app_state_final.conversation.lock().unwrap();
|
|
||||||
conversation.push(ChatMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: response_text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
status_label.set_text("Ready");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
status_label.set_text(&format!("Error: {}", e));
|
|
||||||
|
|
||||||
// Show error in chat
|
|
||||||
let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
let mut end_iter = chat_buffer.end_iter();
|
|
||||||
chat_buffer.delete(&mut start_iter, &mut end_iter);
|
|
||||||
let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark);
|
|
||||||
chat_buffer.insert(&mut insert_iter, &format!("[Error: {}]", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change button back to Send state when generation completes
|
|
||||||
{
|
|
||||||
let mut state = button_state.lock().unwrap();
|
|
||||||
*state = ButtonState::Send;
|
|
||||||
}
|
|
||||||
update_button_state(&action_button, ButtonState::Send);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
fn runtime() -> &'static Runtime {
|
|
||||||
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
|
||||||
RUNTIME.get_or_init(|| {
|
|
||||||
Runtime::new().expect("Setting up tokio runtime needs to succeed.")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_chat_view() -> (TextView, TextBuffer, Arc<MarkdownProcessor>) {
|
|
||||||
let chat_view = TextView::new();
|
|
||||||
chat_view.set_editable(false);
|
|
||||||
chat_view.set_cursor_visible(false);
|
|
||||||
chat_view.set_wrap_mode(WrapMode::WordChar);
|
|
||||||
chat_view.add_css_class("chat-text");
|
|
||||||
|
|
||||||
let chat_buffer = TextBuffer::new(None);
|
|
||||||
chat_view.set_buffer(Some(&chat_buffer));
|
|
||||||
|
|
||||||
// Set up markdown formatting tags
|
|
||||||
let markdown_processor = Arc::new(MarkdownProcessor::new());
|
|
||||||
markdown_processor.setup_tags(&chat_buffer);
|
|
||||||
|
|
||||||
(chat_view, chat_buffer, markdown_processor)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_models(model_list: StringList, dropdown: DropDown, status_label: Label, app_state: AppState) {
|
|
||||||
status_label.set_text("Loading models...");
|
|
||||||
|
|
||||||
// Create communication channel
|
|
||||||
let (sender, receiver) = async_channel::bounded(1);
|
|
||||||
|
|
||||||
// Spawn tokio task for API call
|
|
||||||
runtime().spawn(async move {
|
|
||||||
let result = api::fetch_models(&app_state.ollama_url).await;
|
|
||||||
let _ = sender.send(result).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle response on main loop
|
|
||||||
spawn_future_local(clone!(
|
|
||||||
#[weak] model_list,
|
|
||||||
#[weak] dropdown,
|
|
||||||
#[weak] status_label,
|
|
||||||
async move {
|
|
||||||
if let Ok(result) = receiver.recv().await {
|
|
||||||
match result {
|
|
||||||
Ok(models) => {
|
|
||||||
// Clear existing items and add new ones
|
|
||||||
model_list.splice(0, model_list.n_items(),
|
|
||||||
&models.iter().map(|m| m.name.as_str()).collect::<Vec<_>>());
|
|
||||||
|
|
||||||
// Select first model if available
|
|
||||||
if model_list.n_items() > 0 && dropdown.selected() == gtk4::INVALID_LIST_POSITION {
|
|
||||||
dropdown.set_selected(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
status_label.set_text("Ready");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
status_label.set_text(&format!("Error loading models: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_thinking_checkbox_handler(thinking_checkbox: CheckButton, app_state: AppState) {
|
|
||||||
thinking_checkbox.connect_toggled(clone!(
|
|
||||||
#[strong] app_state,
|
|
||||||
move |checkbox| {
|
|
||||||
let is_active = checkbox.is_active();
|
|
||||||
if let Ok(mut thinking_enabled) = app_state.thinking_enabled.lock() {
|
|
||||||
*thinking_enabled = is_active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_to_chat(buffer: &TextBuffer, sender: &str, message: &str, markdown_processor: &MarkdownProcessor) {
|
|
||||||
let mut end_iter = buffer.end_iter();
|
|
||||||
|
|
||||||
// Add spacing if buffer is not empty
|
|
||||||
if buffer.char_count() > 0 {
|
|
||||||
buffer.insert(&mut end_iter, "\n\n");
|
|
||||||
end_iter = buffer.end_iter();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sender label
|
|
||||||
buffer.insert(&mut end_iter, &format!("{}:\n", sender));
|
|
||||||
end_iter = buffer.end_iter();
|
|
||||||
|
|
||||||
// Add message - user messages are always plain text
|
|
||||||
if sender == "You" {
|
|
||||||
buffer.insert(&mut end_iter, message);
|
|
||||||
} else {
|
|
||||||
// For assistant messages, use markdown formatting
|
|
||||||
markdown_processor.insert_formatted_text(buffer, message, &mut end_iter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
164
src/ui/chat.rs
Normal file
164
src/ui/chat.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{TextView, TextBuffer, ScrolledWindow, WrapMode, PolicyType, Box as GtkBox, Orientation};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use crate::markdown_renderer::MarkdownRenderer;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChatView {
|
||||||
|
scrolled_window: ScrolledWindow,
|
||||||
|
text_view: TextView,
|
||||||
|
text_buffer: TextBuffer,
|
||||||
|
main_container: GtkBox,
|
||||||
|
markdown_renderer: Rc<RefCell<MarkdownRenderer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatView {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Create main container that can hold both text and widgets
|
||||||
|
let main_container = GtkBox::new(Orientation::Vertical, 8);
|
||||||
|
|
||||||
|
let scrolled_window = ScrolledWindow::new();
|
||||||
|
scrolled_window.add_css_class("chat-container");
|
||||||
|
scrolled_window.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||||
|
scrolled_window.set_vexpand(true); // Allow vertical expansion
|
||||||
|
scrolled_window.set_hexpand(true); // Allow horizontal expansion
|
||||||
|
|
||||||
|
let text_view = TextView::new();
|
||||||
|
text_view.set_editable(false);
|
||||||
|
text_view.set_cursor_visible(false);
|
||||||
|
text_view.set_wrap_mode(WrapMode::WordChar);
|
||||||
|
text_view.set_hexpand(true);
|
||||||
|
text_view.set_vexpand(true);
|
||||||
|
text_view.add_css_class("chat-text");
|
||||||
|
text_view.set_margin_start(12);
|
||||||
|
text_view.set_margin_end(12);
|
||||||
|
text_view.set_margin_top(12);
|
||||||
|
text_view.set_margin_bottom(12);
|
||||||
|
|
||||||
|
let text_buffer = TextBuffer::new(None);
|
||||||
|
text_view.set_buffer(Some(&text_buffer));
|
||||||
|
|
||||||
|
let markdown_renderer = Rc::new(RefCell::new(MarkdownRenderer::new()));
|
||||||
|
|
||||||
|
// Add text view to container
|
||||||
|
main_container.append(&text_view);
|
||||||
|
main_container.set_vexpand(true);
|
||||||
|
main_container.set_hexpand(true);
|
||||||
|
|
||||||
|
scrolled_window.set_child(Some(&main_container));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
scrolled_window,
|
||||||
|
text_view,
|
||||||
|
text_buffer,
|
||||||
|
main_container,
|
||||||
|
markdown_renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget(&self) -> &ScrolledWindow {
|
||||||
|
&self.scrolled_window
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer(&self) -> &TextBuffer {
|
||||||
|
&self.text_buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_message(&self, sender: &str, message: &str, config: &Config) {
|
||||||
|
let mut end_iter = self.text_buffer.end_iter();
|
||||||
|
|
||||||
|
// Add spacing if buffer is not empty
|
||||||
|
if self.text_buffer.char_count() > 0 {
|
||||||
|
self.text_buffer.insert(&mut end_iter, "\n\n");
|
||||||
|
end_iter = self.text_buffer.end_iter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sender label with bold formatting
|
||||||
|
let sender_tag = gtk4::TextTag::new(Some("sender"));
|
||||||
|
sender_tag.set_weight(700);
|
||||||
|
sender_tag.set_property("pixels-below-lines", 4);
|
||||||
|
|
||||||
|
// Add the sender tag to the buffer's tag table if it's not already there
|
||||||
|
let tag_table = self.text_buffer.tag_table();
|
||||||
|
if tag_table.lookup("sender").is_none() {
|
||||||
|
tag_table.add(&sender_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.text_buffer.insert_with_tags(&mut end_iter, &format!("{}:\n", sender), &[&sender_tag]);
|
||||||
|
end_iter = self.text_buffer.end_iter();
|
||||||
|
|
||||||
|
// Add message - format markdown for assistant, plain text for user
|
||||||
|
if sender == "You" {
|
||||||
|
self.text_buffer.insert(&mut end_iter, message);
|
||||||
|
} else {
|
||||||
|
self.insert_formatted_text(message, &mut end_iter, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_formatted_text(&self, markdown_text: &str, iter: &mut gtk4::TextIter, config: &Config) {
|
||||||
|
let mut renderer = self.markdown_renderer.borrow_mut();
|
||||||
|
renderer.render_markdown_at_iter(&self.text_buffer, markdown_text, iter, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_to_bottom(&self) {
|
||||||
|
let adjustment = self.scrolled_window.vadjustment();
|
||||||
|
adjustment.set_value(adjustment.upper() - adjustment.page_size());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_mark_at_end(&self) -> gtk4::TextMark {
|
||||||
|
self.text_buffer.create_mark(None, &self.text_buffer.end_iter(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_formatted_at_mark(&self, mark: >k4::TextMark, content: &str, config: &Config) {
|
||||||
|
let mut start_iter = self.text_buffer.iter_at_mark(mark);
|
||||||
|
let mut end_iter = self.text_buffer.end_iter();
|
||||||
|
|
||||||
|
self.text_buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
let mut insert_iter = self.text_buffer.iter_at_mark(mark);
|
||||||
|
self.insert_formatted_text(content, &mut insert_iter, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_streaming_markdown(&self, mark: >k4::TextMark, accumulated_content: &str, config: &Config) {
|
||||||
|
// Store the current scroll position
|
||||||
|
let adjustment = self.scrolled_window.vadjustment();
|
||||||
|
let scroll_position = adjustment.value();
|
||||||
|
let at_bottom = scroll_position >= (adjustment.upper() - adjustment.page_size() - 50.0);
|
||||||
|
|
||||||
|
// Get the mark position
|
||||||
|
let mut start_iter = self.text_buffer.iter_at_mark(mark);
|
||||||
|
let mut end_iter = self.text_buffer.end_iter();
|
||||||
|
|
||||||
|
// Delete existing content from mark to end
|
||||||
|
self.text_buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
// Get a fresh iterator at the mark position after deletion
|
||||||
|
let _insert_iter = self.text_buffer.iter_at_mark(mark);
|
||||||
|
|
||||||
|
// Render markdown directly to the main buffer
|
||||||
|
// We use a separate method to avoid conflicts with the borrow checker
|
||||||
|
self.render_markdown_at_mark(mark, accumulated_content, config);
|
||||||
|
|
||||||
|
// Restore scroll position or scroll to bottom if we were at the bottom
|
||||||
|
if at_bottom {
|
||||||
|
self.scroll_to_bottom();
|
||||||
|
} else {
|
||||||
|
adjustment.set_value(scroll_position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_at_mark(&self, mark: >k4::TextMark, content: &str, config: &Config) {
|
||||||
|
let mut insert_iter = self.text_buffer.iter_at_mark(mark);
|
||||||
|
|
||||||
|
// Create a new scope to ensure the borrow is dropped
|
||||||
|
{
|
||||||
|
let mut renderer = self.markdown_renderer.borrow_mut();
|
||||||
|
renderer.render_markdown_at_iter(&self.text_buffer, content, &mut insert_iter, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_chat_view() -> ChatView {
|
||||||
|
ChatView::new()
|
||||||
|
}
|
||||||
65
src/ui/controls.rs
Normal file
65
src/ui/controls.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GtkBox, Orientation, DropDown, Label, StringList};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ControlsArea {
|
||||||
|
pub container: GtkBox,
|
||||||
|
pub model_dropdown: DropDown,
|
||||||
|
pub status_label: Label,
|
||||||
|
models: StringList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlsArea {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let container = GtkBox::new(Orientation::Horizontal, 16);
|
||||||
|
container.set_margin_top(16);
|
||||||
|
|
||||||
|
// Model selection
|
||||||
|
let models = StringList::new(&[]);
|
||||||
|
let model_dropdown = DropDown::new(Some(models.clone()), None::<gtk4::Expression>);
|
||||||
|
model_dropdown.set_hexpand(true);
|
||||||
|
|
||||||
|
// Status label
|
||||||
|
let status_label = Label::new(Some("Ready"));
|
||||||
|
status_label.set_hexpand(true);
|
||||||
|
status_label.set_halign(gtk4::Align::End);
|
||||||
|
status_label.add_css_class("status-label");
|
||||||
|
|
||||||
|
container.append(&model_dropdown);
|
||||||
|
container.append(&status_label);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
container,
|
||||||
|
model_dropdown,
|
||||||
|
status_label,
|
||||||
|
models,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_models(&self, model_names: &[impl AsRef<str>]) {
|
||||||
|
// Clear existing models
|
||||||
|
let model_names_refs: Vec<&str> = model_names.iter().map(|s| s.as_ref()).collect();
|
||||||
|
self.models.splice(0, self.models.n_items(), &model_names_refs);
|
||||||
|
// Select first model if available
|
||||||
|
if !model_names.is_empty() {
|
||||||
|
self.model_dropdown.set_selected(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_model(&self) -> Option<String> {
|
||||||
|
let selected = self.model_dropdown.selected();
|
||||||
|
if selected != gtk4::INVALID_LIST_POSITION {
|
||||||
|
self.models.string(selected).map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&self, status: &str) {
|
||||||
|
self.status_label.set_text(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_controls() -> ControlsArea {
|
||||||
|
ControlsArea::new()
|
||||||
|
}
|
||||||
352
src/ui/handlers.rs
Normal file
352
src/ui/handlers.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::glib::{spawn_future_local, clone};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::state::{SharedState, AppResult, AppError, ButtonState};
|
||||||
|
use crate::ui::{chat::ChatView, input::InputArea, controls::ControlsArea};
|
||||||
|
use crate::api;
|
||||||
|
|
||||||
|
pub fn setup_handlers(
|
||||||
|
shared_state: SharedState,
|
||||||
|
chat_view: ChatView,
|
||||||
|
input_area: InputArea,
|
||||||
|
controls_area: ControlsArea,
|
||||||
|
) {
|
||||||
|
// Load models on startup
|
||||||
|
load_models(shared_state.clone(), &controls_area);
|
||||||
|
|
||||||
|
// Setup action button handler
|
||||||
|
setup_action_button_handler(shared_state.clone(), &chat_view, &input_area, &controls_area);
|
||||||
|
|
||||||
|
// Setup keyboard shortcut
|
||||||
|
setup_keyboard_shortcut(&input_area, shared_state.clone());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_action_button_handler(
|
||||||
|
shared_state: SharedState,
|
||||||
|
chat_view: &ChatView,
|
||||||
|
input_area: &InputArea,
|
||||||
|
controls_area: &ControlsArea,
|
||||||
|
) {
|
||||||
|
let action_button = &input_area.action_button;
|
||||||
|
let text_buffer = input_area.text_buffer.clone();
|
||||||
|
let chat_view_clone = chat_view.clone();
|
||||||
|
let controls_clone = controls_area.clone();
|
||||||
|
let button_clone = action_button.clone();
|
||||||
|
|
||||||
|
action_button.connect_clicked(clone!(
|
||||||
|
#[strong] shared_state,
|
||||||
|
#[strong] text_buffer,
|
||||||
|
#[strong] chat_view_clone,
|
||||||
|
#[strong] controls_clone,
|
||||||
|
#[strong] button_clone,
|
||||||
|
move |_| {
|
||||||
|
if let Err(e) = handle_action_button_click(
|
||||||
|
&shared_state,
|
||||||
|
&text_buffer,
|
||||||
|
&chat_view_clone,
|
||||||
|
&controls_clone,
|
||||||
|
&button_clone
|
||||||
|
) {
|
||||||
|
controls_clone.set_status(&format!("Error: {}", e));
|
||||||
|
update_button_state(&shared_state, &button_clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Initialize button appearance
|
||||||
|
update_button_state(&shared_state, action_button);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_action_button_click(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
text_buffer: >k4::TextBuffer,
|
||||||
|
chat_view: &ChatView,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let current_state = shared_state.borrow().button_state;
|
||||||
|
|
||||||
|
match current_state {
|
||||||
|
ButtonState::Send => handle_send_click(shared_state, text_buffer, chat_view, controls, button),
|
||||||
|
ButtonState::Stop => handle_stop_click(shared_state, controls, button),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_send_click(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
text_buffer: >k4::TextBuffer,
|
||||||
|
chat_view: &ChatView,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
// Validate input
|
||||||
|
let text = get_input_text(text_buffer)?;
|
||||||
|
let model = get_selected_model(controls)?;
|
||||||
|
|
||||||
|
// Clear input and start generation
|
||||||
|
clear_input(text_buffer);
|
||||||
|
set_generating_state(shared_state, controls, button, true);
|
||||||
|
|
||||||
|
// Add user message to conversation and chat
|
||||||
|
shared_state.borrow_mut().add_user_message(text.clone());
|
||||||
|
let config = shared_state.borrow().config.clone();
|
||||||
|
chat_view.append_message("You", &text, &config);
|
||||||
|
|
||||||
|
// Start streaming
|
||||||
|
start_streaming_task(shared_state, chat_view, controls, button, model);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_stop_click(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
shared_state.borrow_mut().abort_current_task();
|
||||||
|
update_button_state(shared_state, button);
|
||||||
|
controls.set_status("Generation stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_generating_state(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
generating: bool
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let mut state = shared_state.borrow_mut();
|
||||||
|
state.set_generating(generating);
|
||||||
|
state.set_status(if generating {
|
||||||
|
"Assistant is typing...".to_string()
|
||||||
|
} else {
|
||||||
|
"Ready".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
update_button_state(shared_state, button);
|
||||||
|
controls.set_status(if generating {
|
||||||
|
"Assistant is typing..."
|
||||||
|
} else {
|
||||||
|
"Ready"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_button_state(shared_state: &SharedState, button: >k4::Button) {
|
||||||
|
let is_generating = shared_state.borrow().is_generating;
|
||||||
|
|
||||||
|
if is_generating {
|
||||||
|
button.set_label("Stop");
|
||||||
|
button.remove_css_class("send-button");
|
||||||
|
button.add_css_class("stop-button");
|
||||||
|
} else {
|
||||||
|
button.set_label("Send");
|
||||||
|
button.remove_css_class("stop-button");
|
||||||
|
button.add_css_class("send-button");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_input_text(text_buffer: >k4::TextBuffer) -> AppResult<String> {
|
||||||
|
let text = text_buffer.text(&text_buffer.start_iter(), &text_buffer.end_iter(), false);
|
||||||
|
let text = text.trim();
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(AppError::Validation("Message cannot be empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_model(controls: &ControlsArea) -> AppResult<String> {
|
||||||
|
controls.get_selected_model()
|
||||||
|
.ok_or_else(|| AppError::Validation("Please select a model first".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_input(text_buffer: >k4::TextBuffer) {
|
||||||
|
text_buffer.delete(&mut text_buffer.start_iter(), &mut text_buffer.end_iter());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_streaming_task(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
chat_view: &ChatView,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
model: String,
|
||||||
|
) {
|
||||||
|
let (content_sender, content_receiver) = async_channel::bounded::<String>(100);
|
||||||
|
let (result_sender, result_receiver) = async_channel::bounded(1);
|
||||||
|
|
||||||
|
// Extract data from shared state for API call
|
||||||
|
let (conversation, ollama_url) = {
|
||||||
|
let state = shared_state.borrow();
|
||||||
|
(state.conversation.clone(), state.ollama_url.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn API task
|
||||||
|
let task_handle = runtime().spawn(async move {
|
||||||
|
let result = api::send_chat_request_streaming(
|
||||||
|
&ollama_url,
|
||||||
|
&model,
|
||||||
|
&std::sync::Arc::new(std::sync::Mutex::new(conversation)),
|
||||||
|
content_sender,
|
||||||
|
).await;
|
||||||
|
let _ = result_sender.send(result).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store task handle
|
||||||
|
shared_state.borrow_mut().current_task = Some(task_handle);
|
||||||
|
|
||||||
|
// Setup streaming handlers
|
||||||
|
setup_streaming_handlers(
|
||||||
|
shared_state,
|
||||||
|
chat_view,
|
||||||
|
controls,
|
||||||
|
button,
|
||||||
|
content_receiver,
|
||||||
|
result_receiver
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_streaming_handlers(
|
||||||
|
shared_state: &SharedState,
|
||||||
|
chat_view: &ChatView,
|
||||||
|
controls: &ControlsArea,
|
||||||
|
button: >k4::Button,
|
||||||
|
content_receiver: async_channel::Receiver<String>,
|
||||||
|
result_receiver: async_channel::Receiver<Result<(String, Option<String>), Box<dyn std::error::Error + Send + Sync>>>,
|
||||||
|
) {
|
||||||
|
// Setup UI structure for streaming
|
||||||
|
let mut end_iter = chat_view.buffer().end_iter();
|
||||||
|
chat_view.buffer().insert(&mut end_iter, "\n\nAssistant:");
|
||||||
|
|
||||||
|
|
||||||
|
// Create response mark for regular content
|
||||||
|
let mut end_iter = chat_view.buffer().end_iter();
|
||||||
|
chat_view.buffer().insert(&mut end_iter, "\n");
|
||||||
|
let response_mark = chat_view.create_mark_at_end();
|
||||||
|
|
||||||
|
// Handle response content updates with live markdown
|
||||||
|
let response_mark_clone = response_mark.clone();
|
||||||
|
let chat_view_content = chat_view.clone();
|
||||||
|
|
||||||
|
let shared_state_streaming = shared_state.clone();
|
||||||
|
|
||||||
|
spawn_future_local(async move {
|
||||||
|
let mut accumulated_content = String::new();
|
||||||
|
|
||||||
|
while let Ok(content_batch) = content_receiver.recv().await {
|
||||||
|
accumulated_content.push_str(&content_batch);
|
||||||
|
let config = shared_state_streaming.borrow().config.clone();
|
||||||
|
chat_view_content.update_streaming_markdown(&response_mark_clone, &accumulated_content, &config);
|
||||||
|
chat_view_content.scroll_to_bottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle final result
|
||||||
|
let shared_state_final = shared_state.clone();
|
||||||
|
let controls_final = controls.clone();
|
||||||
|
let button_final = button.clone();
|
||||||
|
let chat_view_final = chat_view.clone();
|
||||||
|
|
||||||
|
|
||||||
|
spawn_future_local(async move {
|
||||||
|
if let Ok(result) = result_receiver.recv().await {
|
||||||
|
match result {
|
||||||
|
Ok(response_text) => {
|
||||||
|
// Apply final markdown formatting
|
||||||
|
let config = shared_state_final.borrow().config.clone();
|
||||||
|
chat_view_final.insert_formatted_at_mark(&response_mark, &response_text.0, &config);
|
||||||
|
|
||||||
|
// Update conversation state
|
||||||
|
shared_state_final.borrow_mut().add_assistant_message(response_text.0);
|
||||||
|
set_generating_state(&shared_state_final, &controls_final, &button_final, false);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Display error in response section
|
||||||
|
let error_message = format!("**Error:** {}", e);
|
||||||
|
let config = shared_state_final.borrow().config.clone();
|
||||||
|
chat_view_final.insert_formatted_at_mark(&response_mark, &error_message, &config);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
set_generating_state(&shared_state_final, &controls_final, &button_final, false);
|
||||||
|
controls_final.set_status(&format!("Error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_view_final.scroll_to_bottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_keyboard_shortcut(input_area: &InputArea, shared_state: SharedState) {
|
||||||
|
let input_controller = gtk4::EventControllerKey::new();
|
||||||
|
let action_button = input_area.action_button.clone();
|
||||||
|
|
||||||
|
input_controller.connect_key_pressed(clone!(
|
||||||
|
#[strong] shared_state,
|
||||||
|
#[strong] action_button,
|
||||||
|
move |_, key, _, modifier| {
|
||||||
|
if key == gtk4::gdk::Key::Return &&
|
||||||
|
modifier.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
||||||
|
|
||||||
|
let is_ready = !shared_state.borrow().is_generating;
|
||||||
|
if is_ready {
|
||||||
|
action_button.emit_clicked();
|
||||||
|
}
|
||||||
|
gtk4::glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
gtk4::glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
input_area.text_view.add_controller(input_controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_models(shared_state: SharedState, controls: &ControlsArea) {
|
||||||
|
controls.set_status("Loading models...");
|
||||||
|
|
||||||
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
|
let controls_clone = controls.clone();
|
||||||
|
|
||||||
|
// Extract URL from shared state for API call
|
||||||
|
let ollama_url = shared_state.borrow().ollama_url.clone();
|
||||||
|
|
||||||
|
// Spawn API task
|
||||||
|
runtime().spawn(async move {
|
||||||
|
let result = api::fetch_models(&ollama_url).await;
|
||||||
|
let _ = sender.send(result).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
spawn_future_local(async move {
|
||||||
|
if let Ok(result) = receiver.recv().await {
|
||||||
|
match result {
|
||||||
|
Ok(models) => {
|
||||||
|
let model_names: Vec<String> = models.into_iter().map(|m| m.name).collect();
|
||||||
|
controls_clone.set_models(&model_names);
|
||||||
|
|
||||||
|
// Update shared state with first model
|
||||||
|
if !model_names.is_empty() {
|
||||||
|
shared_state.borrow_mut().selected_model = Some(model_names[0].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
controls_clone.set_status("Ready");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
controls_clone.set_status(&format!("Error loading models: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime() -> &'static Runtime {
|
||||||
|
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
||||||
|
RUNTIME.get_or_init(|| {
|
||||||
|
Runtime::new().expect("Failed to create tokio runtime")
|
||||||
|
})
|
||||||
|
}
|
||||||
64
src/ui/input.rs
Normal file
64
src/ui/input.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GtkBox, Orientation, TextView, TextBuffer, Button, ScrolledWindow, PolicyType, WrapMode};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct InputArea {
|
||||||
|
pub container: GtkBox,
|
||||||
|
pub text_view: TextView,
|
||||||
|
pub text_buffer: TextBuffer,
|
||||||
|
pub action_button: Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputArea {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let container = GtkBox::new(Orientation::Horizontal, 0);
|
||||||
|
|
||||||
|
// Text input area with proper sizing
|
||||||
|
let scrolled_window = ScrolledWindow::new();
|
||||||
|
scrolled_window.add_css_class("input-container");
|
||||||
|
scrolled_window.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||||
|
scrolled_window.set_min_content_height(80); // Minimum height
|
||||||
|
scrolled_window.set_max_content_height(200); // Maximum height
|
||||||
|
scrolled_window.set_propagate_natural_height(true);
|
||||||
|
scrolled_window.set_hexpand(true);
|
||||||
|
scrolled_window.set_vexpand(false);
|
||||||
|
|
||||||
|
let text_view = TextView::new();
|
||||||
|
text_view.set_wrap_mode(WrapMode::WordChar);
|
||||||
|
text_view.set_editable(true); // Explicitly set editable
|
||||||
|
text_view.set_cursor_visible(true); // Make cursor visible
|
||||||
|
text_view.set_accepts_tab(false); // Don't consume tab events
|
||||||
|
text_view.add_css_class("input-text");
|
||||||
|
text_view.set_hexpand(true);
|
||||||
|
text_view.set_vexpand(true);
|
||||||
|
|
||||||
|
// Set some placeholder-like behavior
|
||||||
|
let text_buffer = text_view.buffer();
|
||||||
|
|
||||||
|
scrolled_window.set_child(Some(&text_view));
|
||||||
|
|
||||||
|
// Action button container
|
||||||
|
let button_container = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
button_container.set_halign(gtk4::Align::End);
|
||||||
|
|
||||||
|
let action_button = Button::with_label("Send");
|
||||||
|
action_button.add_css_class("send-button");
|
||||||
|
|
||||||
|
button_container.append(&action_button);
|
||||||
|
|
||||||
|
// Assemble container
|
||||||
|
container.append(&scrolled_window);
|
||||||
|
container.append(&button_container);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
container,
|
||||||
|
text_view,
|
||||||
|
text_buffer,
|
||||||
|
action_button,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_input_area() -> InputArea {
|
||||||
|
InputArea::new()
|
||||||
|
}
|
||||||
4
src/ui/mod.rs
Normal file
4
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod chat;
|
||||||
|
pub mod input;
|
||||||
|
pub mod controls;
|
||||||
|
pub mod handlers;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue