use crate::llm::LLMProvider; use crate::shared::models::UserSession; use crate::shared::state::AppState; use log::{debug, info, warn}; use rhai::Dynamic; use rhai::Engine; use serde_json::json; use std::error::Error; use std::fs; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; pub fn create_site_keyword(state: &AppState, user: UserSession, engine: &mut Engine) { let state_clone = state.clone(); let user_clone = user; engine .register_custom_syntax( ["CREATE", "SITE", "$expr$", ",", "$expr$", ",", "$expr$"], true, move |context, inputs| { if inputs.len() < 3 { return Err("Not enough arguments for CREATE SITE".into()); } let alias = context.eval_expression_tree(&inputs[0])?; let template_dir = context.eval_expression_tree(&inputs[1])?; let prompt = context.eval_expression_tree(&inputs[2])?; let config = state_clone .config .as_ref() .expect("Config must be initialized") .clone(); let s3 = state_clone.s3_client.clone().map(std::sync::Arc::new); let bucket = state_clone.bucket_name.clone(); let bot_id = user_clone.bot_id.to_string(); #[cfg(feature = "llm")] let llm: Option> = Some(state_clone.llm_provider.clone()); #[cfg(not(feature = "llm"))] let llm: Option> = None; let fut = create_site(config, s3, bucket, bot_id, llm, alias, template_dir, prompt); let result = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) .map_err(|e| format!("Site creation failed: {}", e))?; Ok(Dynamic::from(result)) }, ) .unwrap(); } async fn create_site( config: crate::config::AppConfig, s3: Option>, bucket: String, bot_id: String, llm: Option>, alias: Dynamic, template_dir: Dynamic, prompt: Dynamic, ) -> Result> { let alias_str = alias.to_string(); let template_dir_str = template_dir.to_string(); let prompt_str = prompt.to_string(); info!( "CREATE SITE: {} from template {}", alias_str, template_dir_str ); let base_path = PathBuf::from(&config.site_path); let template_path = base_path.join(&template_dir_str); let combined_content = load_templates(&template_path)?; let generated_html = generate_html_from_prompt(llm, &combined_content, &prompt_str).await?; let drive_path = format!("apps/{}", alias_str); store_to_drive(s3.as_ref(), &bucket, &bot_id, &drive_path, &generated_html).await?; let serve_path = base_path.join(&alias_str); sync_to_serve_path(&serve_path, &generated_html, &template_path).await?; info!( "CREATE SITE: {} completed, available at /apps/{}", alias_str, alias_str ); Ok(format!("/apps/{}", alias_str)) } fn load_templates(template_path: &std::path::Path) -> Result> { let mut combined_content = String::new(); if !template_path.exists() { return Err(format!("Template directory not found: {}", template_path.display()).into()); } for entry in fs::read_dir(template_path).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "html") { let mut file = fs::File::open(&path).map_err(|e| e.to_string())?; let mut contents = String::new(); file.read_to_string(&mut contents) .map_err(|e| e.to_string())?; use std::fmt::Write; let _ = writeln!(combined_content, "", path.display()); combined_content.push_str(&contents); combined_content.push_str("\n\n--- TEMPLATE SEPARATOR ---\n\n"); debug!("Loaded template: {}", path.display()); } } if combined_content.is_empty() { return Err("No HTML templates found in template directory".into()); } Ok(combined_content) } async fn generate_html_from_prompt( llm: Option>, templates: &str, prompt: &str, ) -> Result> { let full_prompt = format!( r#"You are an expert HTML/HTMX developer. Generate a complete HTML application. TEMPLATE FILES FOR REFERENCE: {} USER REQUEST: {} REQUIREMENTS: 1. Clone the template structure and styling 2. Use ONLY local _assets (htmx.min.js, app.js, styles.css) - NO external CDNs 3. Use HTMX for all data operations: - hx-get="/api/db/TABLE" for lists - hx-post="/api/db/TABLE" for create - hx-put="/api/db/TABLE/ID" for update - hx-delete="/api/db/TABLE/ID" for delete 4. Include search with hx-trigger="keyup changed delay:300ms" 5. Generate semantic, accessible HTML 6. App context is automatic - just use /api/db/* paths OUTPUT: Complete index.html file only, no explanations."#, templates, prompt ); let html = match llm { Some(provider) => { let messages = json!([{ "role": "user", "content": full_prompt }]); match provider .generate(&full_prompt, &messages, "gpt-4o-mini", "") .await { Ok(response) => { let cleaned = extract_html_from_response(&response); if cleaned.contains(" { warn!("LLM generation failed: {}, using placeholder", e); generate_placeholder_html(prompt) } } } None => { debug!("No LLM provider configured, using placeholder HTML"); generate_placeholder_html(prompt) } }; debug!("Generated HTML ({} bytes)", html.len()); Ok(html) } fn extract_html_from_response(response: &str) -> String { let trimmed = response.trim(); if trimmed.starts_with("```html") { let without_prefix = trimmed.strip_prefix("```html").unwrap_or(trimmed); let without_suffix = without_prefix .trim() .strip_suffix("```") .unwrap_or(without_prefix); return without_suffix.trim().to_string(); } if trimmed.starts_with("```") { let without_prefix = trimmed.strip_prefix("```").unwrap_or(trimmed); let without_suffix = without_prefix .trim() .strip_suffix("```") .unwrap_or(without_prefix); return without_suffix.trim().to_string(); } trimmed.to_string() } fn generate_placeholder_html(prompt: &str) -> String { format!( r##" App

Generated App

Prompt: {}

Data

Loading...
"##, prompt ) } async fn store_to_drive( s3: Option<&std::sync::Arc>, bucket: &str, bot_id: &str, drive_path: &str, html_content: &str, ) -> Result<(), Box> { let Some(s3_client) = s3 else { debug!("S3 not configured, skipping drive storage"); return Ok(()); }; let key = format!("{}.gbdrive/{}/index.html", bot_id, drive_path); info!("Storing to drive: s3://{}/{}", bucket, key); s3_client .put_object() .bucket(bucket) .key(&key) .body(html_content.as_bytes().to_vec().into()) .content_type("text/html") .send() .await .map_err(|e| format!("Failed to store to drive: {}", e))?; let schema_key = format!("{}.gbdrive/{}/schema.json", bot_id, drive_path); let schema = r#"{"tables": {}, "version": 1}"#; s3_client .put_object() .bucket(bucket) .key(&schema_key) .body(schema.as_bytes().to_vec().into()) .content_type("application/json") .send() .await .map_err(|e| format!("Failed to store schema: {}", e))?; Ok(()) } async fn sync_to_serve_path( serve_path: &std::path::Path, html_content: &str, template_path: &std::path::Path, ) -> Result<(), Box> { fs::create_dir_all(serve_path).map_err(|e| format!("Failed to create serve path: {}", e))?; let index_path = serve_path.join("index.html"); fs::write(&index_path, html_content) .map_err(|e| format!("Failed to write index.html: {}", e))?; info!("Written: {}", index_path.display()); let template_assets = template_path.join("_assets"); let serve_assets = serve_path.join("_assets"); if template_assets.exists() { copy_dir_recursive(&template_assets, &serve_assets)?; info!("Copied assets to: {}", serve_assets.display()); } else { fs::create_dir_all(&serve_assets) .map_err(|e| format!("Failed to create assets dir: {}", e))?; let htmx_path = serve_assets.join("htmx.min.js"); if !htmx_path.exists() { fs::write(&htmx_path, "/* HTMX - include from CDN or bundle */") .map_err(|e| format!("Failed to write htmx: {}", e))?; } let styles_path = serve_assets.join("styles.css"); if !styles_path.exists() { fs::write(&styles_path, DEFAULT_STYLES) .map_err(|e| format!("Failed to write styles: {}", e))?; } let app_js_path = serve_assets.join("app.js"); if !app_js_path.exists() { fs::write(&app_js_path, DEFAULT_APP_JS) .map_err(|e| format!("Failed to write app.js: {}", e))?; } } let schema_path = serve_path.join("schema.json"); fs::write(&schema_path, r#"{"tables": {}, "version": 1}"#) .map_err(|e| format!("Failed to write schema.json: {}", e))?; Ok(()) } fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), Box> { fs::create_dir_all(dst) .map_err(|e| format!("Failed to create dir {}: {}", dst.display(), e))?; for entry in fs::read_dir(src).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { fs::copy(&src_path, &dst_path) .map_err(|e| format!("Failed to copy file {}: {}", src_path.display(), e))?; } } Ok(()) } const DEFAULT_STYLES: &str = r" :root { --primary: #0ea5e9; --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --bg: #ffffff; --text: #1e293b; --border: #e2e8f0; --radius: 8px; } @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; --text: #f1f5f9; --border: #334155; } } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } header { padding: 1rem 2rem; border-bottom: 1px solid var(--border); } main { padding: 2rem; max-width: 1200px; margin: 0 auto; } h1, h2, h3 { margin-bottom: 1rem; } input, select, textarea { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 1rem; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--primary); } button { padding: 0.5rem 1rem; background: var(--primary); color: white; border: none; border-radius: var(--radius); cursor: pointer; font-size: 1rem; } button:hover { opacity: 0.9; } form { display: flex; gap: 0.5rem; margin: 1rem 0; } table { width: 100%; border-collapse: collapse; } th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border); } .htmx-indicator { opacity: 0; transition: opacity 0.2s; } .htmx-request .htmx-indicator { opacity: 1; } "; const DEFAULT_APP_JS: &str = r" function toast(message, type = 'info') { const el = document.createElement('div'); el.className = 'toast toast-' + type; el.textContent = message; el.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:1rem;background:#333;color:#fff;border-radius:8px;z-index:9999;'; document.body.appendChild(el); setTimeout(() => el.remove(), 3000); } document.body.addEventListener('htmx:afterSwap', function(e) { console.log('Data updated:', e.detail.target.id); }); document.body.addEventListener('htmx:responseError', function(e) { toast('Error: ' + (e.detail.xhr.responseText || 'Request failed'), 'error'); }); function openModal(id) { document.getElementById(id)?.classList.add('active'); } function closeModal(id) { document.getElementById(id)?.classList.remove('active'); } ";