2fb47520b3
feat(debug-logger): add DebugSignalLogger plugin for event logging and inspection feat(plugins): introduce built-in plugins module and integrate debug logger feat(plugin-integration): create integration layer for mapping Iced Messages to CoreEvents feat(plugin-registry): establish PluginRegistry for managing plugins and event dispatching
540 lines
19 KiB
Rust
540 lines
19 KiB
Rust
//! 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<Message>,
|
|
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()
|
|
}
|