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
+152 -5
View File
@@ -12,6 +12,7 @@
//! These sections are optimized for 4K performance. Modifying them
//! may cause rendering regressions or performance degradation.
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs;
use crate::dock::state::{DockState, PaneType};
use crate::io::tablet::TabletState;
@@ -150,6 +151,8 @@ pub struct HcieIcedApp {
pub layer_style_drag_start: Option<(f32, f32)>,
/// Marching ants animation offset (cycles 0.0..1.0 for dashed line animation).
pub marching_ants_offset: f32,
/// AI chat state (messages, config, streaming).
pub ai_chat_state: AiChatState,
}
/// A recently opened file entry.
@@ -411,6 +414,13 @@ pub enum Message {
AiChatInput(String),
AiChatSend,
AiChatClear,
AiChatProviderChanged(String),
AiChatUrlChanged(String),
AiChatModelChanged(String),
AiChatReasoningToggled(bool),
AiChatCanvasContextToggled(bool),
AiChatStreamChunk(String),
AiChatStreamFinished(Result<String, String>),
// ── Clipboard ───────────────────────────────────────
CopyImage,
@@ -608,6 +618,7 @@ impl HcieIcedApp {
layer_style_dragging: false,
layer_style_drag_start: None,
marching_ants_offset: 0.0,
ai_chat_state: AiChatState::default(),
};
// Apply saved settings to tool state
@@ -2236,15 +2247,151 @@ impl HcieIcedApp {
}
// ── AI Chat ───────────────────────────────────
Message::AiChatInput(_text) => {
// Handled by the chat panel directly
Message::AiChatInput(text) => {
self.ai_chat_state.input = text;
}
Message::AiChatSend => {
// Placeholder: would send to LLM
log::info!("AI chat send not yet implemented");
let user_msg = self.ai_chat_state.input.trim().to_string();
if user_msg.is_empty() || self.ai_chat_state.is_streaming {
return Task::none();
}
// Add user message to history
self.ai_chat_state.messages.push(ChatMessage {
role: "user".to_string(),
content: user_msg.clone(),
is_reasoning: false,
});
self.ai_chat_state.input.clear();
self.ai_chat_state.is_streaming = true;
self.ai_chat_state.current_response.clear();
self.ai_chat_state.current_reasoning.clear();
// Build the final prompt with optional canvas context
let mut final_prompt = user_msg.clone();
if self.ai_chat_state.config.canvas_context {
let doc = &self.documents[self.active_doc];
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let layers = doc.engine.layer_infos();
let mut meta = format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h);
for l in &layers {
meta.push_str(&format!(
"\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)",
l.name, l.layer_type, l.id, l.visible, l.opacity * 100.0
));
}
meta.push_str("]");
final_prompt.push_str(&meta);
}
// Build messages JSON for API
let messages_json = ai_chat::build_messages_json(
&self.ai_chat_state.messages,
&final_prompt,
SYSTEM_PROMPT,
);
// Clone config for the async task
let url = self.ai_chat_state.config.base_url.clone();
let model = self.ai_chat_state.config.model.clone();
// Spawn async streaming task — runs on Iced's executor
return Task::perform(
ai_chat::stream_llm_response(url, model, messages_json),
Message::AiChatStreamFinished,
);
}
Message::AiChatClear => {
// Handled by the chat panel directly
self.ai_chat_state.messages.clear();
self.ai_chat_state.current_response.clear();
self.ai_chat_state.current_reasoning.clear();
self.ai_chat_state.is_streaming = false;
}
Message::AiChatProviderChanged(provider) => {
match provider.as_str() {
"Ollama" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama;
self.ai_chat_state.config.base_url = "http://localhost:11434".to_string();
}
"Claude" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude;
self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string();
}
"OpenAI" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI;
self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string();
}
_ => {}
}
}
Message::AiChatUrlChanged(url) => {
self.ai_chat_state.config.base_url = url;
}
Message::AiChatModelChanged(model) => {
self.ai_chat_state.config.model = model;
}
Message::AiChatReasoningToggled(enabled) => {
self.ai_chat_state.config.reasoning_enabled = enabled;
}
Message::AiChatCanvasContextToggled(enabled) => {
self.ai_chat_state.config.canvas_context = enabled;
}
Message::AiChatStreamChunk(_chunk) => {
// Handled by the streaming task internally
}
Message::AiChatStreamFinished(result) => {
self.ai_chat_state.is_streaming = false;
match result {
Ok(full_response) => {
// Execute any embedded tool calls
let tool_results = ai_chat::execute_inline_tools(
&full_response,
&mut self.documents[self.active_doc].engine,
);
for res in &tool_results {
self.ai_chat_state.messages.push(ChatMessage {
role: "tool".to_string(),
content: res.clone(),
is_reasoning: false,
});
}
// Execute any DSL code blocks
if let Some(dsl_code) = ai_chat::extract_dsl_block(&full_response) {
let commands = ai_chat::parse_dsl_script(
&dsl_code,
&self.documents[self.active_doc].engine,
);
for cmd in &commands {
ai_chat::execute_command_on_engine(
&mut self.documents[self.active_doc].engine,
cmd,
);
}
self.ai_chat_state.messages.push(ChatMessage {
role: "tool".to_string(),
content: "▶ Automatically executed AI procedural drawing script.".to_string(),
is_reasoning: false,
});
self.refresh_composite_if_needed();
}
// Add the full assistant response to history
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: full_response,
is_reasoning: false,
});
}
Err(e) => {
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: format!("❌ Connection Error: {}", e),
is_reasoning: false,
});
}
}
}
// ── Clipboard ─────────────────────────────────