feat(iced): implement AI chat with streaming SSE and tool execution

This commit is contained in:
2026-07-15 16:31:25 +03:00
parent f2b03265ba
commit ba6a7f8de2
7 changed files with 1052 additions and 49 deletions
@@ -1,39 +1,92 @@
//! AI Chat panel — AI assistant for image editing.
//! AI Chat panel — AI assistant with streaming SSE responses, tool execution, and DSL scripts.
//!
//! Full implementation of the AI chat interface including provider selection,
//! streaming response display, chat bubbles, reasoning toggle, and canvas context toggle.
//! Uses ThemeColors for consistent styling.
use crate::ai_chat::{AiChatState, ChatMessage};
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};
/// Build the AI chat panel.
pub fn view(input: &str, messages: &[(String, String)], colors: ThemeColors) -> Element<'static, Message> {
let provider_row = row![
text("Provider:").size(10),
button(text("Ollama").size(10)).on_press(Message::NoOp).padding([4, 8]),
button(text("Claude").size(10)).on_press(Message::NoOp).padding([4, 8]),
button(text("OpenAI").size(10)).on_press(Message::NoOp).padding([4, 8]),
/// Build the AI chat panel with full streaming UI.
pub fn view(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);
.spacing(4)
.align_y(iced::Alignment::Center);
let mut msg_list = column![].spacing(4);
// ── 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]);
if messages.is_empty() {
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);
// ── Message list ──
let mut msg_list = column![].spacing(6);
if state.messages.is_empty() && !state.is_streaming {
// Welcome message
let welcome = container(
column![
text("AI Assistant").size(12),
text("Ask me anything about image editing!").size(11),
text("").size(8),
text("I can help with:").size(10),
text(" - Creating and editing layers").size(10),
text(" - Applying filters and effects").size(10),
text(" - Drawing shapes and text").size(10),
text(" - Color adjustments").size(10),
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),
@@ -42,46 +95,84 @@ pub fn view(input: &str, messages: &[(String, String)], colors: ThemeColors) ->
msg_list = msg_list.push(welcome);
} else {
for (role, content) in messages {
let (label, color) = if role == "user" {
("You", colors.accent)
} else {
("AI", iced::Color::from_rgb(0.4, 0.9, 0.4))
};
// Render chat history
for msg in &state.messages {
let bubble = render_message_bubble(msg, colors);
msg_list = msg_list.push(bubble);
}
let content_owned = content.clone();
let msg_text = text(content_owned).size(11);
let msg_text = msg_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(color),
});
let bubble = container(
column![text(label).size(10), msg_text].spacing(2)
// Render streaming reasoning if enabled
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)
)
.width(Length::Fill)
.padding(8)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
border: iced::Border::default().rounded(6),
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(bubble);
msg_list = msg_list.push(reasoning_bubble);
}
// Render streaming response if in progress
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);
}
// Streaming indicator
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);
}
}
let input_owned = input.to_string();
let input_field = text_input("Ask the AI assistant...", &input_owned)
// ── 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 = button(text("Send").size(11)).on_press(Message::AiChatSend).padding([6, 12]);
let clear_btn = button(text("Clear").size(11)).on_press(Message::AiChatClear).padding([6, 12]);
let input_row = row![input_field, send_btn, clear_btn].spacing(4);
let send_btn = button(text("Send").size(10))
.on_press(Message::AiChatSend)
.padding([6, 10]);
let clear_btn = button(text("Clear").size(10))
.on_press(Message::AiChatClear)
.padding([6, 10]);
let input_row = row![input_field, send_btn, clear_btn]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Assemble panel ──
let panel = column![
text("AI Assistant").size(13),
text("AI Assistant").size(13).color(colors.text_primary),
provider_row,
config_row,
toggles_row,
horizontal_rule(1),
scrollable(msg_list).height(Length::Fill),
horizontal_rule(1),
@@ -96,3 +187,100 @@ pub fn view(input: &str, messages: &[(String, String)], colors: ThemeColors) ->
.style(move |_theme| styles::panel_background(colors))
.into()
}
/// Render a single chat message as a styled bubble.
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
match msg.role.as_str() {
"user" => {
// User bubble — right-aligned, blue tint
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" => {
// Tool result — monospace pill
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()
}
_ => {
// Assistant bubble — left-aligned, slate tint
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()
}
}
}
/// Create a provider selection button with active state highlighting.
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()),
_ => Message::NoOp,
};
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 | iced::widget::button::Status::Hovered => {
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::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_secondary,
border: iced::Border::default().rounded(4),
..Default::default()
},
}
})
.into()
}