//! AI Assistant panel with two sub-tabs: ComfyUI pipeline viewer and AI Chat. //! //! Mirrors the egui AI Assistant panel which has both ComfyUI and AI Chat tabs. //! The active tab is controlled by `app.ai_assistant_tab` (0 = ComfyUI, 1 = AI Chat). //! Uses ThemeColors for consistent styling across both tabs. use crate::ai_chat::{AiChatState, ChatMessage, ComfyStatus, ComfyUiState}; use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; use crate::app::HcieIcedApp; /// Build the AI Assistant panel with tab switcher and sub-tab content. pub fn view(app: &HcieIcedApp, colors: ThemeColors) -> Element<'static, Message> { let tab = app.ai_assistant_tab; // ── Tab bar ── let comfy_tab_btn = tab_button("ComfyUI", tab == 0, 0, colors); let chat_tab_btn = tab_button("AI Chat", tab == 1, 1, colors); let tab_bar = row![comfy_tab_btn, chat_tab_btn] .spacing(0) .align_y(iced::Alignment::Center); // ── Content area ── let content: Element<'static, Message> = match tab { 0 => comfy_tab_content(&app.comfy_state, colors), _ => chat_tab_content(&app.ai_chat_state, colors), }; let panel = column![ tab_bar, horizontal_rule(1), content, ] .spacing(0) .padding(8); container(panel) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| styles::panel_background(colors)) .into() } /// A tab button that highlights when active. fn tab_button( label: &str, active: bool, tab_index: usize, colors: ThemeColors, ) -> Element<'static, Message> { let label_owned = label.to_string(); let bg = if active { colors.accent } else { colors.bg_active }; let fg = if active { iced::Color::WHITE } else { colors.text_secondary }; button(text(label_owned).size(11)) .on_press(Message::AiAssistantTabChanged(tab_index)) .padding([4, 12]) .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { let (background, text_color) = match status { iced::widget::button::Status::Active => (bg, fg), iced::widget::button::Status::Hovered => { (colors.accent_hover, iced::Color::WHITE) } iced::widget::button::Status::Pressed => { (colors.accent, iced::Color::WHITE) } iced::widget::button::Status::Disabled => { (colors.bg_app, colors.text_disabled) } }; iced::widget::button::Style { background: Some(iced::Background::Color(background)), text_color, border: iced::Border::default().rounded(4), ..Default::default() } }) .into() } /// ComfyUI tab content — placeholder pipeline viewer. fn comfy_tab_content(state: &ComfyUiState, colors: ThemeColors) -> Element<'static, Message> { let status_label = match state.status { ComfyStatus::Unknown => "Unknown", ComfyStatus::Running => "Running", ComfyStatus::Error => "Error", }; let status_color = match state.status { ComfyStatus::Unknown => colors.text_disabled, ComfyStatus::Running => colors.accent_green, ComfyStatus::Error => iced::Color::from_rgb(0.95, 0.3, 0.3), }; let url_input = text_input("http://127.0.0.1:8188", &state.url) .on_input(|s| Message::ComfyUiUrlChanged(s)) .size(10) .width(Length::Fill); let check_btn = button(text("Check Status").size(10)) .on_press(Message::ComfyUiCheckStatus) .padding([4, 10]); let status_row = row![ text("Status:").size(10).color(colors.text_secondary), text(status_label).size(10).color(status_color), ] .spacing(6) .align_y(iced::Alignment::Center); column![ text("ComfyUI Pipeline").size(13).color(colors.text_primary), text("").size(4), text("Connect to a running ComfyUI instance to run") .size(10) .color(colors.text_disabled), text("stable diffusion pipelines and AI image generation.") .size(10) .color(colors.text_disabled), text("").size(8), row![ text("Server URL:").size(10).color(colors.text_secondary), url_input, check_btn, ] .spacing(4) .align_y(iced::Alignment::Center), status_row, text("").size(8), container( text("Pipeline playback and node graph editing will be added in a future update.") .size(10) .color(colors.text_disabled), ) .padding(8) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_hover)), border: iced::Border::default().rounded(6), ..Default::default() }), ] .spacing(4) .into() } /// AI Chat tab content — streaming SSE chat interface. fn chat_tab_content(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { // ── Provider selector row ── let provider_label = text("Provider:").size(10).color(colors.text_secondary); let ollama_btn = make_provider_btn( "Ollama", state.config.provider == crate::ai_chat::AiProvider::Ollama, colors, ); let claude_btn = make_provider_btn( "Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors, ); let openai_btn = make_provider_btn( "OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors, ); let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn] .spacing(4) .align_y(iced::Alignment::Center); // ── Config section (URL, model, toggles) ── let url_label = text("URL:").size(9).color(colors.text_secondary); let url_input = text_input("http://localhost:11434", &state.config.base_url) .on_input(|s| Message::AiChatUrlChanged(s)) .size(10) .width(Length::Fill); let model_label = text("Model:").size(9).color(colors.text_secondary); let model_input = text_input("qwen2.5:latest", &state.config.model) .on_input(|s| Message::AiChatModelChanged(s)) .size(10) .width(Length::Fill); let config_row = row![url_label, url_input, model_label, model_input,] .spacing(4) .align_y(iced::Alignment::Center); // ── Toggles row ── let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary); let reasoning_btn = button( text(if state.config.reasoning_enabled { "ON" } else { "OFF" }) .size(9), ) .on_press(Message::AiChatReasoningToggled( !state.config.reasoning_enabled, )) .padding([2, 6]); let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary); let canvas_btn = button( text(if state.config.canvas_context { "ON" } else { "OFF" }) .size(9), ) .on_press(Message::AiChatCanvasContextToggled( !state.config.canvas_context, )) .padding([2, 6]); let toggles_row = row![ reasoning_label, reasoning_btn, iced::widget::Space::with_width(8), canvas_label, canvas_btn, ] .spacing(4) .align_y(iced::Alignment::Center); let hint = text("Model discovery is unavailable; enter the exact provider model ID.") .size(9) .color(colors.text_disabled); // ── Message list ── let mut msg_list = column![].spacing(6); if state.messages.is_empty() && !state.is_streaming { let welcome = container( column![ text("AI Creative Assistant").size(13).color(colors.accent), text("").size(4), text("Hello! I am your AI Drawing Partner.") .size(11) .color(colors.text_secondary), text("").size(4), text("Ask me to paint landscapes, add details,") .size(10) .color(colors.text_disabled), text("apply blur filters, or manipulate layers!") .size(10) .color(colors.text_disabled), ] .spacing(2), ) .padding(12) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_hover)), border: iced::Border::default().rounded(8), ..Default::default() }); msg_list = msg_list.push(welcome); } else { for msg in &state.messages { let bubble = render_message_bubble(msg, colors); msg_list = msg_list.push(bubble); } if state.config.reasoning_enabled && !state.current_reasoning.is_empty() { let reasoning_bubble = container( column![ text("Deep Thoughts:") .size(10) .color(iced::Color::from_rgb(0.85, 0.47, 0.02)), text(state.current_reasoning.clone()) .size(10) .color(colors.text_secondary), ] .spacing(2), ) .padding(8) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.15, 0.12, 0.08, 1.0, ))), border: iced::Border::default() .rounded(6) .color(iced::Color::from_rgb(0.85, 0.47, 0.02)), ..Default::default() }); msg_list = msg_list.push(reasoning_bubble); } if state.is_streaming && !state.current_response.is_empty() { let streaming_bubble = container( text(state.current_response.clone()) .size(11) .color(colors.text_primary), ) .padding(8) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.18, 0.22, 0.28, 1.0, ))), border: iced::Border::default() .rounded(6) .color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)), ..Default::default() }); msg_list = msg_list.push(streaming_bubble); } if state.is_streaming { let indicator = row![ text("...").size(10).color(colors.text_disabled), text("AI is thinking...") .size(10) .color(colors.text_disabled), ] .spacing(4); msg_list = msg_list.push(indicator); } } // ── Input area ── let input_field = text_input("Ask the AI assistant...", &state.input) .on_input(|s| Message::AiChatInput(s)) .size(11) .width(Length::Fill); let send_btn = action_button( "Send", (!state.is_streaming && !state.input.trim().is_empty()).then_some(Message::AiChatSend), colors, ); let stream_btn = action_button( "Cancel", state.is_streaming.then_some(Message::AiChatCancel), colors, ); let clear_btn = action_button("Clear", Some(Message::AiChatClear), colors); let copy_btn = action_button( "Copy all", (!state.messages.is_empty() || state.is_streaming).then_some(Message::AiChatCopyTranscript), colors, ); let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn] .spacing(4) .align_y(iced::Alignment::Center); column![ provider_row, config_row, hint, toggles_row, horizontal_rule(1), scrollable(msg_list).height(Length::Fill), horizontal_rule(1), input_row, ] .spacing(4) .into() } fn action_button( label: &'static str, message: Option, colors: ThemeColors, ) -> Element<'static, Message> { let mut control = button(text(label).size(10)).padding([6, 10]); if let Some(message) = message { control = control.on_press(message); } control .style(move |_theme, status| { let (background, text_color, border) = match status { iced::widget::button::Status::Active => { (colors.bg_active, colors.text_primary, colors.border_high) } iced::widget::button::Status::Hovered => { (colors.accent_hover, iced::Color::WHITE, colors.accent) } iced::widget::button::Status::Pressed => { (colors.accent, iced::Color::WHITE, colors.text_primary) } iced::widget::button::Status::Disabled => { (colors.bg_app, colors.text_disabled, colors.border_low) } }; iced::widget::button::Style { background: Some(iced::Background::Color(background)), text_color, border: iced::Border::default().rounded(3).color(border).width(1), ..Default::default() } }) .into() } fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> { if msg.is_reasoning { return container( column![ text("Reasoning").size(9).color(colors.text_secondary), text(msg.content.clone()) .size(10) .color(colors.text_secondary), ] .spacing(2), ) .padding(8) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_app)), border: iced::Border::default().rounded(6).color(colors.border_high), ..Default::default() }) .into(); } match msg.role.as_str() { "user" => { let label = text("You").size(9).color(colors.accent); let content = text(msg.content.clone()) .size(11) .color(colors.text_primary); let bubble = container(column![label, content].spacing(2)) .padding(8) .max_width(400.0) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.16, 0.33, 0.58, 1.0, ))), border: iced::Border::default().rounded(6), ..Default::default() }); row![iced::widget::Space::with_width(Length::Fill), bubble].into() } "tool" => { let content = text(msg.content.clone()).size(10).color(colors.accent); container(content) .padding([4, 8]) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_hover)), border: iced::Border::default().rounded(4).color(colors.border_low), ..Default::default() }) .into() } _ => { let label = text("AI").size(9).color(colors.accent_green); let content = text(msg.content.clone()) .size(11) .color(colors.text_primary); let bubble = container(column![label, content].spacing(2)) .padding(8) .max_width(400.0) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.18, 0.22, 0.28, 1.0, ))), border: iced::Border::default().rounded(6), ..Default::default() }); row![bubble, iced::widget::Space::with_width(Length::Fill)].into() } } } fn make_provider_btn( label: &str, is_active: bool, colors: ThemeColors, ) -> Element<'static, Message> { let label_owned = label.to_string(); let msg = match label { "Ollama" => Message::AiChatProviderChanged("Ollama".to_string()), "Claude" => Message::AiChatProviderChanged("Claude".to_string()), "OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()), _ => unreachable!("provider buttons are built from known labels"), }; button(text(label_owned).size(10)) .on_press(msg) .padding([4, 8]) .style( move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Active => { if is_active { iced::widget::button::Style { background: Some(iced::Background::Color(colors.accent)), text_color: iced::Color::WHITE, border: iced::Border::default().rounded(4), ..Default::default() } } else { iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_hover)), text_color: colors.text_secondary, border: iced::Border::default().rounded(4), ..Default::default() } } } iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(if is_active { colors.accent_hover } else { colors.bg_active })), text_color: if is_active { iced::Color::WHITE } else { colors.text_primary }, border: iced::Border::default().rounded(4).color(colors.border_high), ..Default::default() }, iced::widget::button::Status::Pressed => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_active)), text_color: colors.text_primary, border: iced::Border::default().rounded(4).color(colors.accent), ..Default::default() }, iced::widget::button::Status::Disabled => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_app)), text_color: colors.text_disabled, border: iced::Border::default().rounded(4), ..Default::default() }, }, ) .into() }