feat(iced): implement AI chat with streaming SSE and tool execution
This commit is contained in:
@@ -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 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user