feat(iced): implement AI script generator with working LLM calls

This commit is contained in:
2026-07-15 17:02:06 +03:00
parent 827d9b1742
commit 3bc5966630
7 changed files with 406 additions and 38 deletions
@@ -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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
match provider_type {
"claude" => generate_via_claude(provider, prompt).await,
"openai" => generate_via_openai(provider, prompt).await,
_ => generate_via_ollama(provider, prompt).await,
}
}
@@ -163,6 +163,20 @@ pub struct HcieIcedApp {
pub script_error_line: Option<usize>, pub script_error_line: Option<usize>,
/// Whether a script is currently executing. /// Whether a script is currently executing.
pub script_is_running: bool, 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<String>,
/// 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. /// A recently opened file entry.
@@ -438,6 +452,17 @@ pub enum Message {
AiChatStreamChunk(String), AiChatStreamChunk(String),
AiChatStreamFinished(Result<String, String>), AiChatStreamFinished(Result<String, String>),
// ── AI Script Generator ─────────────────────────────
AiScriptPromptChanged(String),
AiScriptProviderChanged(String),
AiScriptModelChanged(String),
AiScriptUrlChanged(String),
AiScriptGenerate,
AiScriptRun,
AiScriptCopy,
AiScriptClear,
AiScriptResult(Result<String, String>),
// ── Clipboard ─────────────────────────────────────── // ── Clipboard ───────────────────────────────────────
CopyImage, CopyImage,
PasteImage, PasteImage,
@@ -642,6 +667,13 @@ impl HcieIcedApp {
script_error: None, script_error: None,
script_error_line: None, script_error_line: None,
script_is_running: false, 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 // 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 ───────────────────────────────── // ── Clipboard ─────────────────────────────────
Message::CopyImage => { Message::CopyImage => {
let doc = &self.documents[self.active_doc]; let doc = &self.documents[self.active_doc];
@@ -3064,6 +3194,7 @@ impl HcieIcedApp {
(7, 7) => self.dock.reopen_pane(PaneType::Geometry), (7, 7) => self.dock.reopen_pane(PaneType::Geometry),
(7, 8) => self.dock.reopen_pane(PaneType::Script), (7, 8) => self.dock.reopen_pane(PaneType::Script),
(7, 9) => self.dock.reopen_pane(PaneType::LayerDetails), (7, 9) => self.dock.reopen_pane(PaneType::LayerDetails),
(7, 10) => self.dock.reopen_pane(PaneType::AiScript),
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// More menu (8) — Photopea-style // More menu (8) — Photopea-style
@@ -44,6 +44,8 @@ pub enum PaneType {
Geometry, Geometry,
/// Tool Settings panel. /// Tool Settings panel.
ToolSettings, ToolSettings,
/// AI Script Generator panel.
AiScript,
} }
impl PaneType { impl PaneType {
@@ -62,6 +64,7 @@ impl PaneType {
PaneType::LayerDetails => "Layer Details", PaneType::LayerDetails => "Layer Details",
PaneType::Geometry => "Geometry", PaneType::Geometry => "Geometry",
PaneType::ToolSettings => "Tool Settings", PaneType::ToolSettings => "Tool Settings",
PaneType::AiScript => "AI Script Generator",
} }
} }
} }
@@ -225,7 +228,7 @@ impl DockState {
if let Some(target) = target_pane { if let Some(target) = target_pane {
let axis = match pane_type { 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::Horizontal
} }
_ => iced::widget::pane_grid::Axis::Vertical, _ => iced::widget::pane_grid::Axis::Vertical,
@@ -94,6 +94,9 @@ pub fn dock_view<'a>(
PaneType::AiChat => { PaneType::AiChat => {
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors) crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
} }
PaneType::AiScript => {
crate::panels::ai_script_panel::view(app)
}
PaneType::LayerDetails => { PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors) 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::LayerDetails => Some("icons/panel-layers.svg"),
PaneType::Geometry => Some("icons/vector_rect.svg"), PaneType::Geometry => Some("icons/vector_rect.svg"),
PaneType::ToolSettings => Some("icons/panel-settings.svg"), PaneType::ToolSettings => Some("icons/panel-settings.svg"),
PaneType::AiScript => Some("icons/panel-ai.svg"),
} }
} }
@@ -3,6 +3,7 @@
//! Launches the Iced application with the HCIE image editor. //! Launches the Iced application with the HCIE image editor.
mod ai_chat; mod ai_chat;
mod ai_script;
mod app; mod app;
mod canvas; mod canvas;
mod color_picker; mod color_picker;
@@ -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. //! Uses an LLM to generate HCIE-Script DSL commands from natural language
//! Supports Ollama, Claude, and OpenAI providers. //! 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::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the AI script panel. /// Build the AI script panel view from application state.
/// ///
/// Provides a UI for: /// Displays provider selection, URL/model inputs, a prompt text area,
/// - Entering a natural language prompt /// action buttons (Generate, Clear, Run, Copy), and the generated script
/// - Selecting an AI provider (Ollama, Claude, OpenAI) /// output with error display.
/// - Generating a script from the prompt pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
/// - Viewing and running the generated script // ── Provider selector row ────────────────────────────
#[allow(dead_code)]
pub fn view() -> Element<'static, Message> {
let provider_label = text("Provider").size(10); let provider_label = text("Provider").size(10);
let provider_selector = row![ let ollama_btn = button(text("Ollama").size(10))
button(text("Ollama").size(10)).on_press(Message::NoOp).padding([4, 8]), .on_press(Message::AiScriptProviderChanged("ollama".to_string()))
button(text("Claude").size(10)).on_press(Message::NoOp).padding([4, 8]), .padding([4, 8]);
button(text("OpenAI").size(10)).on_press(Message::NoOp).padding([4, 8]), let claude_btn = button(text("Claude").size(10))
] .on_press(Message::AiScriptProviderChanged("claude".to_string()))
.spacing(4); .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") // ── URL and model inputs ─────────────────────────────
.width(Length::Fill); 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") let model_input = text_input("Model", &app.ai_script_model)
.width(Length::Fill); .width(Length::Fill)
.on_input(Message::AiScriptModelChanged);
let prompt_input = text_input("Describe what to draw...", "") // ── Prompt input ─────────────────────────────────────
.width(Length::Fill); 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)) // ── Action buttons ───────────────────────────────────
.on_press(Message::NoOp) let generate_btn = if app.ai_script_is_running {
.padding([6, 12]); 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)) let run_btn = button(text("Run Script").size(11))
.on_press(Message::NoOp) .on_press(Message::AiScriptRun)
.padding([6, 12]); .padding([6, 12]);
let copy_btn = button(text("Copy").size(11)) let copy_btn = button(text("Copy to Editor").size(11))
.on_press(Message::NoOp) .on_press(Message::AiScriptCopy)
.padding([6, 12]); .padding([6, 12]);
let clear_btn = button(text("Clear").size(11)) let clear_btn = button(text("Clear").size(11))
.on_press(Message::NoOp) .on_press(Message::AiScriptClear)
.padding([6, 12]); .padding([6, 12]);
// Script output area // ── Script output area ───────────────────────────────
let script_output = text("Generated script will appear here...") let script_content = if app.ai_script_output.is_empty() {
.size(10) text("Generated script will appear here...").size(10)
; } else {
text(app.ai_script_output.clone()).size(10)
};
let script_area = container( let script_area = container(
scrollable(script_output).height(Length::Fill) scrollable(script_content).height(Length::Fill)
) )
.padding(8) .padding(8)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
@@ -64,6 +79,17 @@ pub fn view() -> Element<'static, Message> {
..Default::default() ..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![ let panel = column![
text("AI Script Generator").size(13), text("AI Script Generator").size(13),
horizontal_rule(1), horizontal_rule(1),
@@ -73,9 +99,12 @@ pub fn view() -> Element<'static, Message> {
model_input, model_input,
horizontal_rule(1), horizontal_rule(1),
prompt_input, prompt_input,
row![generate_btn, run_btn, copy_btn, clear_btn].spacing(4), row![generate_btn, clear_btn].spacing(4),
horizontal_rule(1), horizontal_rule(1),
script_area, script_area,
error_section,
horizontal_rule(1),
row![run_btn, copy_btn].spacing(4),
] ]
.spacing(4) .spacing(4)
.padding(8); .padding(8);
@@ -283,6 +283,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Geometry"), MenuItem::new("Geometry"),
MenuItem::new("Script"), MenuItem::new("Script"),
MenuItem::new("Layer Details"), MenuItem::new("Layer Details"),
MenuItem::new("AI Script Generator"),
], ],
}, },
// ── More menu (Photopea-style) ── // ── More menu (Photopea-style) ──