From 3bc5966630fca3bb45a84d2b792ae7a9b3519063 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Wed, 15 Jul 2026 17:02:06 +0300 Subject: [PATCH] feat(iced): implement AI script generator with working LLM calls --- .../crates/hcie-iced-gui/src/ai_script.rs | 199 ++++++++++++++++++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 131 ++++++++++++ .../crates/hcie-iced-gui/src/dock/state.rs | 5 +- .../crates/hcie-iced-gui/src/dock/view.rs | 4 + .../crates/hcie-iced-gui/src/main.rs | 1 + .../src/panels/ai_script_panel.rs | 103 +++++---- .../crates/hcie-iced-gui/src/panels/menus.rs | 1 + 7 files changed, 406 insertions(+), 38 deletions(-) create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs b/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs new file mode 100644 index 0000000..9ecd5e9 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs @@ -0,0 +1,199 @@ +//! AI Script Generator — LLM-powered procedural drawing script generation. +//! +//! Provides async functions to call Ollama, Claude, and OpenAI APIs to generate +//! HCIE-Script DSL commands from natural language descriptions. Also includes +/// the system prompt, provider configuration, and output cleaning utilities. + +/// System prompt sent to LLMs when generating HCIE-Script DSL commands. +pub const SYSTEM_PROMPT: &str = r#"You are HCIE-Script, a code generator for a procedural drawing DSL. +Output ONLY valid HCIE-Script DSL commands with no explanation, no markdown, no code fences. + +Available commands: +- layer Name — create raster layer +- vector_layer Name — create vector layer +- select_layer Name +- brush STYLE — round, square, pencil, inkpen, charcoal, watercolor, marker, oil, airbrush, spray, clouds, dirt, tree, meadow, rock, bristle, leaf, wetpaint, sketch, hatch, calligraphy, blender, mixer, glow, crayon +- color #RRGGBB — set foreground color +- size N — brush size in pixels +- opacity N — 0..1 +- hardness N — 0..1 +- flow N — 0..2 +- pressure N / pressure_start N / pressure_end N — 0..1 +- wiggle N — per-point jitter +- draw x1,y1 x2,y2 ... — multi-point stroke (coords in pixels) +- line x1,y1 x2,y2 +- rect x1,y1 x2,y2 — filled rectangle in CURRENT color +- circle cx,cy — single brush dab +- triangle x1,y1 x2,y2 x3,y3 — vector triangle +- bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps] +- scatter N x,y w,h [size] — golden-angle scatter +- gradient x1,y1 x2,y2 #hex1 #hex2 ... N +- set key=value key=value — multi-set +- var name value — declare variables, reference as $name +- repeat N { ... } — loop with $i (index), $n (count) +- blend MODE — normal, multiply, overlay, screen, etc. + +Golden rules: +1. Create a layer BEFORE drawing on it. +2. Use brush BEFORE draw/line/bezier. +3. Drawing commands operate on the CURRENT layer and CURRENT brush state. +4. Coordinates are pixel values (0..800 width, 0..600 height). +5. Use gradient for smooth skies and backgrounds. +6. Use scatter for grass, flowers, stars, foliage. +7. Use bezier/curve for smooth natural lines. +8. Build scenes back-to-front: sky → background → midground → foreground. +"#; + +/// LLM provider configuration. +pub struct Provider { + pub base_url: String, + pub model: String, +} + +/// Generate a script via the Ollama /api/chat endpoint (async). +/// +/// Sends the system prompt and user prompt as a chat completion request with +/// `stream: false`. Returns the cleaned DSL script text on success. +pub async fn generate_via_ollama(provider: &Provider, prompt: &str) -> Result { + let url = format!("{}/api/chat", provider.base_url.trim_end_matches('/')); + let body = serde_json::json!({ + "model": provider.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt} + ], + "stream": false, + "options": { "num_predict": 2048 } + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Client error: {}", e))?; + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("API error: {}", resp.status())); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let content = json["message"]["content"] + .as_str() + .ok_or_else(|| "No content in response".to_string())?; + + Ok(clean_script_output(content)) +} + +/// Generate a script via the Claude /v1/messages endpoint (async). +/// +/// Uses the Anthropic Messages API format with the system prompt as a +/// top-level `system` field. Returns the cleaned DSL script text. +pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result { + let url = format!("{}/v1/messages", provider.base_url.trim_end_matches('/')); + let body = serde_json::json!({ + "model": provider.model, + "max_tokens": 2048, + "system": SYSTEM_PROMPT, + "messages": [ + {"role": "user", "content": prompt} + ] + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Client error: {}", e))?; + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("API error {}: {}", status, text)); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let content = json["content"][0]["text"] + .as_str() + .ok_or_else(|| "No content in response".to_string())?; + + Ok(clean_script_output(content)) +} + +/// Generate a script via the OpenAI /v1/chat/completions endpoint (async). +/// +/// Uses the standard Chat Completions format with the system prompt as the +/// first message. Returns the cleaned DSL script text. +pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result { + let url = format!("{}/v1/chat/completions", provider.base_url.trim_end_matches('/')); + let body = serde_json::json!({ + "model": provider.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt} + ], + "max_tokens": 2048 + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Client error: {}", e))?; + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("API error {}: {}", status, text)); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let content = json["choices"][0]["message"]["content"] + .as_str() + .ok_or_else(|| "No content in response".to_string())?; + + Ok(clean_script_output(content)) +} + +/// Strip markdown code fences and leading/trailing whitespace from LLM output. +/// +/// LLMs often wrap DSL output in ```dsl, ```script, ```hcie, or plain ``` fences. +/// This function removes those wrappers to extract the raw script text. +pub fn clean_script_output(text: &str) -> String { + let text = text.trim(); + let text = text.strip_prefix("```dsl").unwrap_or(text); + let text = text.strip_prefix("```script").unwrap_or(text); + let text = text.strip_prefix("```hcie").unwrap_or(text); + let text = text.strip_prefix("```").unwrap_or(text); + let text = text.strip_suffix("```").unwrap_or(text); + text.trim().to_string() +} + +/// Generate a script using the given provider type and configuration (async). +/// +/// Dispatches to the appropriate provider function based on the `provider_type` +/// string ("ollama", "claude", or "openai"). Defaults to Ollama. +pub async fn generate_script(provider_type: &str, provider: &Provider, prompt: &str) -> Result { + match provider_type { + "claude" => generate_via_claude(provider, prompt).await, + "openai" => generate_via_openai(provider, prompt).await, + _ => generate_via_ollama(provider, prompt).await, + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 501ae3e..feba3e0 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -163,6 +163,20 @@ pub struct HcieIcedApp { pub script_error_line: Option, /// Whether a script is currently executing. pub script_is_running: bool, + /// AI script generator — natural language prompt input. + pub ai_script_prompt: String, + /// AI script generator — generated DSL script output. + pub ai_script_output: String, + /// AI script generator — last error message (if any). + pub ai_script_error: Option, + /// AI script generator — whether a generation request is in progress. + pub ai_script_is_running: bool, + /// AI script generator — selected provider type ("ollama", "claude", "openai"). + pub ai_script_provider: String, + /// AI script generator — model name for the selected provider. + pub ai_script_model: String, + /// AI script generator — base URL for the selected provider. + pub ai_script_url: String, } /// A recently opened file entry. @@ -438,6 +452,17 @@ pub enum Message { AiChatStreamChunk(String), AiChatStreamFinished(Result), + // ── AI Script Generator ───────────────────────────── + AiScriptPromptChanged(String), + AiScriptProviderChanged(String), + AiScriptModelChanged(String), + AiScriptUrlChanged(String), + AiScriptGenerate, + AiScriptRun, + AiScriptCopy, + AiScriptClear, + AiScriptResult(Result), + // ── Clipboard ─────────────────────────────────────── CopyImage, PasteImage, @@ -642,6 +667,13 @@ impl HcieIcedApp { script_error: None, script_error_line: None, script_is_running: false, + ai_script_prompt: String::new(), + ai_script_output: String::new(), + ai_script_error: None, + ai_script_is_running: false, + ai_script_provider: "ollama".to_string(), + ai_script_model: "qwen2.5:7b".to_string(), + ai_script_url: "http://localhost:11434".to_string(), }; // Apply saved settings to tool state @@ -2452,6 +2484,104 @@ impl HcieIcedApp { } } + // ── AI Script Generator ─────────────────────── + Message::AiScriptPromptChanged(text) => { + self.ai_script_prompt = text; + } + Message::AiScriptProviderChanged(provider) => { + self.ai_script_provider = provider.clone(); + // Set sensible defaults when switching providers + match provider.as_str() { + "ollama" => { + self.ai_script_url = "http://localhost:11434".to_string(); + self.ai_script_model = "qwen2.5:7b".to_string(); + } + "claude" => { + self.ai_script_url = "https://api.anthropic.com".to_string(); + self.ai_script_model = "claude-sonnet-4-20250514".to_string(); + } + "openai" => { + self.ai_script_url = "https://api.openai.com/v1".to_string(); + self.ai_script_model = "gpt-4o".to_string(); + } + _ => {} + } + } + Message::AiScriptModelChanged(model) => { + self.ai_script_model = model; + } + Message::AiScriptUrlChanged(url) => { + self.ai_script_url = url; + } + Message::AiScriptGenerate => { + if self.ai_script_prompt.trim().is_empty() || self.ai_script_is_running { + return Task::none(); + } + self.ai_script_is_running = true; + self.ai_script_error = None; + self.ai_script_output.clear(); + + let prompt = self.ai_script_prompt.clone(); + let provider_type = self.ai_script_provider.clone(); + let provider = crate::ai_script::Provider { + base_url: self.ai_script_url.clone(), + model: self.ai_script_model.clone(), + }; + + return Task::perform( + async move { + crate::ai_script::generate_script(&provider_type, &provider, &prompt).await + }, + Message::AiScriptResult, + ); + } + Message::AiScriptRun => { + if self.ai_script_output.is_empty() { + return Task::none(); + } + match crate::script::parser::parse_dsl_script(&self.ai_script_output) { + Ok(actions) => { + self.ai_script_error = None; + crate::script::executor::execute_actions( + &mut self.documents[self.active_doc].engine, + &actions, + ); + self.documents[self.active_doc].modified = true; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + Err(e) => { + self.ai_script_error = Some(e); + } + } + } + Message::AiScriptCopy => { + if !self.ai_script_output.is_empty() { + // Copy script to the script editor panel + self.script_text = self.ai_script_output.clone(); + self.script_content = iced::widget::text_editor::Content::with_text( + &self.ai_script_output, + ); + } + } + Message::AiScriptClear => { + self.ai_script_prompt.clear(); + self.ai_script_output.clear(); + self.ai_script_error = None; + self.ai_script_is_running = false; + } + Message::AiScriptResult(result) => { + self.ai_script_is_running = false; + match result { + Ok(script) => { + self.ai_script_output = script; + } + Err(e) => { + self.ai_script_error = Some(e); + } + } + } + // ── Clipboard ───────────────────────────────── Message::CopyImage => { let doc = &self.documents[self.active_doc]; @@ -3064,6 +3194,7 @@ impl HcieIcedApp { (7, 7) => self.dock.reopen_pane(PaneType::Geometry), (7, 8) => self.dock.reopen_pane(PaneType::Script), (7, 9) => self.dock.reopen_pane(PaneType::LayerDetails), + (7, 10) => self.dock.reopen_pane(PaneType::AiScript), // ══════════════════════════════════════════ // More menu (8) — Photopea-style diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index a10c8ab..007959a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -44,6 +44,8 @@ pub enum PaneType { Geometry, /// Tool Settings panel. ToolSettings, + /// AI Script Generator panel. + AiScript, } impl PaneType { @@ -62,6 +64,7 @@ impl PaneType { PaneType::LayerDetails => "Layer Details", PaneType::Geometry => "Geometry", PaneType::ToolSettings => "Tool Settings", + PaneType::AiScript => "AI Script Generator", } } } @@ -225,7 +228,7 @@ impl DockState { if let Some(target) = target_pane { let axis = match pane_type { - PaneType::Layers | PaneType::Properties | PaneType::History | PaneType::LayerDetails => { + PaneType::Layers | PaneType::Properties | PaneType::History | PaneType::LayerDetails | PaneType::AiScript => { iced::widget::pane_grid::Axis::Horizontal } _ => iced::widget::pane_grid::Axis::Vertical, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 837f497..bc13ea8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -94,6 +94,9 @@ pub fn dock_view<'a>( PaneType::AiChat => { crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors) } + PaneType::AiScript => { + crate::panels::ai_script_panel::view(app) + } PaneType::LayerDetails => { let doc = &app.documents[app.active_doc]; crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors) @@ -294,6 +297,7 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> { PaneType::LayerDetails => Some("icons/panel-layers.svg"), PaneType::Geometry => Some("icons/vector_rect.svg"), PaneType::ToolSettings => Some("icons/panel-settings.svg"), + PaneType::AiScript => Some("icons/panel-ai.svg"), } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 880a00f..86ee626 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -3,6 +3,7 @@ //! Launches the Iced application with the HCIE image editor. mod ai_chat; +mod ai_script; mod app; mod canvas; mod color_picker; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs index aa2885b..405f614 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs @@ -1,61 +1,76 @@ -//! AI Script panel — AI-powered script generation. +//! AI Script panel — AI-powered procedural drawing script generation. //! -//! Uses an LLM to generate procedural drawing scripts from natural language. -//! Supports Ollama, Claude, and OpenAI providers. +//! Uses an LLM to generate HCIE-Script DSL commands from natural language +//! descriptions. Supports Ollama, Claude, and OpenAI providers. The generated +//! script can be executed directly or copied to the script editor panel. -use crate::app::Message; +use crate::app::{HcieIcedApp, Message}; use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; -/// Build the AI script panel. +/// Build the AI script panel view from application state. /// -/// Provides a UI for: -/// - Entering a natural language prompt -/// - Selecting an AI provider (Ollama, Claude, OpenAI) -/// - Generating a script from the prompt -/// - Viewing and running the generated script -#[allow(dead_code)] -pub fn view() -> Element<'static, Message> { +/// Displays provider selection, URL/model inputs, a prompt text area, +/// action buttons (Generate, Clear, Run, Copy), and the generated script +/// output with error display. +pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { + // ── Provider selector row ──────────────────────────── let provider_label = text("Provider").size(10); - let provider_selector = row![ - 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]), - ] - .spacing(4); + let ollama_btn = button(text("Ollama").size(10)) + .on_press(Message::AiScriptProviderChanged("ollama".to_string())) + .padding([4, 8]); + let claude_btn = button(text("Claude").size(10)) + .on_press(Message::AiScriptProviderChanged("claude".to_string())) + .padding([4, 8]); + let openai_btn = button(text("OpenAI").size(10)) + .on_press(Message::AiScriptProviderChanged("openai".to_string())) + .padding([4, 8]); + let provider_selector = row![ollama_btn, claude_btn, openai_btn].spacing(4); - let url_input = text_input("Base URL", "http://localhost:11434") - .width(Length::Fill); + // ── URL and model inputs ───────────────────────────── + let url_input = text_input("Base URL", &app.ai_script_url) + .width(Length::Fill) + .on_input(Message::AiScriptUrlChanged); - let model_input = text_input("Model", "llama3") - .width(Length::Fill); + let model_input = text_input("Model", &app.ai_script_model) + .width(Length::Fill) + .on_input(Message::AiScriptModelChanged); - let prompt_input = text_input("Describe what to draw...", "") - .width(Length::Fill); + // ── Prompt input ───────────────────────────────────── + let prompt_input = text_input("Describe what to draw...", &app.ai_script_prompt) + .width(Length::Fill) + .on_input(Message::AiScriptPromptChanged); - let generate_btn = button(text("Generate Script").size(11)) - .on_press(Message::NoOp) - .padding([6, 12]); + // ── Action buttons ─────────────────────────────────── + let generate_btn = if app.ai_script_is_running { + button(text("Generating...").size(11)).padding([6, 12]) + } else { + button(text("Generate Script").size(11)) + .on_press(Message::AiScriptGenerate) + .padding([6, 12]) + }; let run_btn = button(text("Run Script").size(11)) - .on_press(Message::NoOp) + .on_press(Message::AiScriptRun) .padding([6, 12]); - let copy_btn = button(text("Copy").size(11)) - .on_press(Message::NoOp) + let copy_btn = button(text("Copy to Editor").size(11)) + .on_press(Message::AiScriptCopy) .padding([6, 12]); let clear_btn = button(text("Clear").size(11)) - .on_press(Message::NoOp) + .on_press(Message::AiScriptClear) .padding([6, 12]); - // Script output area - let script_output = text("Generated script will appear here...") - .size(10) - ; + // ── Script output area ─────────────────────────────── + let script_content = if app.ai_script_output.is_empty() { + text("Generated script will appear here...").size(10) + } else { + text(app.ai_script_output.clone()).size(10) + }; let script_area = container( - scrollable(script_output).height(Length::Fill) + scrollable(script_content).height(Length::Fill) ) .padding(8) .style(move |_theme| iced::widget::container::Style { @@ -64,6 +79,17 @@ pub fn view() -> Element<'static, Message> { ..Default::default() }); + // ── Error display ──────────────────────────────────── + let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error { + text(format!("Error: {}", err)) + .size(10) + .color(iced::Color::from_rgb(0.9, 0.3, 0.3)) + .into() + } else { + column![].into() + }; + + // ── Assemble panel ─────────────────────────────────── let panel = column![ text("AI Script Generator").size(13), horizontal_rule(1), @@ -73,9 +99,12 @@ pub fn view() -> Element<'static, Message> { model_input, horizontal_rule(1), prompt_input, - row![generate_btn, run_btn, copy_btn, clear_btn].spacing(4), + row![generate_btn, clear_btn].spacing(4), horizontal_rule(1), script_area, + error_section, + horizontal_rule(1), + row![run_btn, copy_btn].spacing(4), ] .spacing(4) .padding(8); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index 6e94299..2c3e7eb 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -283,6 +283,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Geometry"), MenuItem::new("Script"), MenuItem::new("Layer Details"), + MenuItem::new("AI Script Generator"), ], }, // ── More menu (Photopea-style) ──