feat(iced): implement AI script generator with working LLM calls
This commit is contained in:
@@ -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) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user