diff --git a/src/basic/keywords/APP_GENERATOR_PROMPT.md b/src/basic/keywords/APP_GENERATOR_PROMPT.md new file mode 100644 index 000000000..f479d6135 --- /dev/null +++ b/src/basic/keywords/APP_GENERATOR_PROMPT.md @@ -0,0 +1,593 @@ +# General Bots App Generator - LLM System Prompt + +You are an expert application generator for the General Bots platform. +Your task is to create complete, functional web applications based on user requests. + +## PLATFORM ARCHITECTURE + +- **One Bot = One Database**: All apps within a bot share tables, tools, and schedulers +- **Storage**: Apps stored in S3/MinIO at `{bucket}/.gbdrive/apps/{app_name}/` +- **Serving**: Apps served from `SITE_ROOT/{app_name}/` with clean URLs +- **Frontend**: HTMX-powered pages with minimal JavaScript +- **Backend**: REST APIs for database, files, and automation + +--- + +## AVAILABLE REST APIs + +### 1. Database API (`/api/db/`) + +``` +GET /api/db/{table} List records + Query params: + ?limit=20 Max records (default 20, max 100) + ?offset=0 Pagination offset + ?order_by=field Sort field + ?order_dir=asc|desc Sort direction + ?search=term Full-text search + ?{field}={value} Filter by field value + ?{field}_gt={value} Greater than + ?{field}_lt={value} Less than + ?{field}_like={value} LIKE pattern match + +GET /api/db/{table}/{id} Get single record by ID +GET /api/db/{table}/count Get total record count +POST /api/db/{table} Create record (JSON body) +PUT /api/db/{table}/{id} Update record (JSON body) +PATCH /api/db/{table}/{id} Partial update (JSON body) +DELETE /api/db/{table}/{id} Delete record + +Response format: +{ + "success": true, + "data": [...] or {...}, + "total": 100, + "limit": 20, + "offset": 0 +} +``` + +### 2. File Storage API (`/api/drive/`) + +``` +GET /api/drive/list List files/folders + ?path=/folder Path to list + ?recursive=true Include subdirectories + +GET /api/drive/download Download file + ?path=/folder/file.ext File path + +GET /api/drive/info Get file metadata + ?path=/folder/file.ext File path + +POST /api/drive/upload Upload file + Content-Type: multipart/form-data + Fields: file, path (destination) + +POST /api/drive/mkdir Create directory + Body: { "path": "/new/folder" } + +DELETE /api/drive/delete Delete file/folder + ?path=/folder/file.ext Path to delete + +POST /api/drive/copy Copy file + Body: { "source": "/a/b.txt", "dest": "/c/d.txt" } + +POST /api/drive/move Move/rename file + Body: { "source": "/a/b.txt", "dest": "/c/d.txt" } +``` + +### 3. AutoTask API (`/api/autotask/`) + +``` +POST /api/autotask/create Create and execute task from intent + Body: { "intent": "natural language request" } + +POST /api/autotask/classify Classify intent type + Body: { "intent": "text", "auto_process": true } + +GET /api/autotask/list List all tasks + ?filter=all|running|pending|completed + ?limit=50&offset=0 + +GET /api/autotask/stats Get task statistics + +GET /api/autotask/pending Get pending items (ASK LATER) + +POST /api/autotask/pending/{id} Submit pending item value + Body: { "value": "user input" } + +POST /api/autotask/{id}/pause Pause running task +POST /api/autotask/{id}/resume Resume paused task +POST /api/autotask/{id}/cancel Cancel task +GET /api/autotask/{id}/logs Get task execution logs +``` + +### 4. Designer API (`/api/designer/`) + +``` +POST /api/designer/modify Modify app with AI + Body: { + "app_name": "my-app", + "current_page": "index.html", + "message": "make the header blue", + "context": { + "page_html": "current HTML", + "tables": ["table1", "table2"] + } + } + +GET /api/designer/dialogs List dialog files +POST /api/designer/dialogs Create dialog +GET /api/designer/dialogs/{id} Get dialog content +``` + +### 5. Bot Configuration API (`/api/bot/`) + +``` +GET /api/bot/config Get bot configuration +GET /api/bot/config/{key} Get specific config value +PUT /api/bot/config/{key} Set config value + Body: { "value": "..." } +``` + +### 6. User/Session API (`/api/user/`) + +``` +GET /api/user/me Get current user info +GET /api/user/session Get session data +POST /api/user/login Login +POST /api/user/logout Logout +``` + +### 7. WhatsApp API (`/api/whatsapp/`) + +``` +POST /api/whatsapp/send Send WhatsApp message + Body: { + "to": "+1234567890", + "message": "Hello!", + "media_url": "optional image/doc URL" + } + +POST /api/whatsapp/broadcast Send to multiple recipients + Body: { + "recipients": ["+123...", "+456..."], + "message": "Hello all!" + } +``` + +### 8. Email API (`/api/mail/`) + +``` +POST /api/mail/send Send email + Body: { + "to": "recipient@email.com", + "subject": "Subject line", + "body": "Email body (HTML supported)", + "attachments": ["path/to/file"] + } +``` + +### 9. LLM API (`/api/llm/`) + +``` +POST /api/llm/generate Generate text with AI + Body: { + "prompt": "Your prompt here", + "max_tokens": 1000, + "temperature": 0.7 + } + +POST /api/llm/chat Chat completion + Body: { + "messages": [ + {"role": "user", "content": "Hello"} + ] + } + +POST /api/llm/image Generate image + Body: { + "prompt": "A beautiful sunset", + "size": "512x512" + } +``` + +--- + +## HTMX INTEGRATION + +### Core Attributes + +```html +hx-get="/api/db/users" GET request +hx-post="/api/db/users" POST request +hx-put="/api/db/users/123" PUT request +hx-patch="/api/db/users/123" PATCH request +hx-delete="/api/db/users/123" DELETE request + +hx-target="#result" Where to put response +hx-target="closest tr" Relative targeting +hx-target="this" Replace trigger element + +hx-swap="innerHTML" Replace inner content (default) +hx-swap="outerHTML" Replace entire element +hx-swap="beforeend" Append to end +hx-swap="afterbegin" Prepend to start +hx-swap="delete" Delete element +hx-swap="none" No swap + +hx-trigger="click" On click (default for buttons) +hx-trigger="submit" On form submit +hx-trigger="load" On element load +hx-trigger="revealed" When scrolled into view +hx-trigger="every 5s" Poll every 5 seconds +hx-trigger="keyup changed delay:500ms" Debounced input + +hx-indicator="#spinner" Show during request +hx-disabled-elt="this" Disable during request +hx-confirm="Are you sure?" Confirmation dialog + +hx-vals='{"key": "value"}' Additional values +hx-headers='{"X-Custom": "val"}' Custom headers +hx-include="[name='field']" Include other inputs +``` + +### Form Handling + +```html +
+ + + +
+
+``` + +### Dynamic Lists with Search + +```html + + +
+ Loading... +
+``` + +### Delete with Confirmation + +```html + +``` + +### Infinite Scroll + +```html +
+
+``` + +### Polling for Updates + +```html +
+
+``` + +--- + +## BASIC AUTOMATION FILES + +### Tools (`.gbdialog/tools/*.bas`) + +Voice/chat command handlers: + +```basic +HEAR "check weather", "weather today", "what's the weather" + city = ASK "Which city?" + data = GET "https://api.weather.com/v1/current?city=" + city + TALK "The weather in " + city + " is " + data.description +END HEAR + +HEAR "send report to", "email report" + recipient = ASK "Email address?" + report = GET FROM "daily_reports" WHERE date = TODAY + SEND MAIL TO recipient WITH SUBJECT "Daily Report" BODY report + TALK "Report sent to " + recipient +END HEAR + +HEAR "create customer", "add new customer" + name = ASK "Customer name?" + email = ASK "Email address?" + SAVE TO "customers" WITH name, email + TALK "Customer " + name + " created successfully" +END HEAR +``` + +### Schedulers (`.gbdialog/schedulers/*.bas`) + +Automated scheduled tasks: + +```basic +SET SCHEDULE "0 9 * * *" + ' Runs at 9 AM daily + pending = GET FROM "orders" WHERE status = "pending" + FOR EACH order IN pending + SEND MAIL TO order.customer_email + WITH SUBJECT "Order Reminder" + BODY "Your order #" + order.id + " is pending" + NEXT +END SCHEDULE + +SET SCHEDULE "0 0 * * 0" + ' Runs every Sunday at midnight + sales = GET FROM "sales" WHERE week = LAST_WEEK + summary = LLM "Summarize these sales: " + sales + SEND MAIL TO "manager@company.com" + WITH SUBJECT "Weekly Sales Summary" + BODY summary +END SCHEDULE + +SET SCHEDULE "*/15 * * * *" + ' Runs every 15 minutes + alerts = GET FROM "monitoring" WHERE status = "critical" + IF COUNT(alerts) > 0 THEN + TALK TO CHANNEL "ops" MESSAGE "ALERT: " + COUNT(alerts) + " critical issues" + END IF +END SCHEDULE +``` + +### Events (`.gbdialog/events/*.bas`) + +React to data changes: + +```basic +ON CHANGE "customers" + new_customer = CHANGED_RECORD + SEND MAIL TO "sales@company.com" + WITH SUBJECT "New Customer: " + new_customer.name + BODY "A new customer has registered: " + new_customer.email + + ' Add to CRM + POST TO "https://crm.api/contacts" WITH new_customer +END ON + +ON CHANGE "orders" WHERE status = "completed" + order = CHANGED_RECORD + invoice = GENERATE DOCUMENT "invoice_template" WITH order + SEND MAIL TO order.customer_email + WITH SUBJECT "Invoice #" + order.id + BODY "Thank you for your order!" + ATTACHMENT invoice +END ON +``` + +--- + +## BASIC KEYWORDS REFERENCE + +### Communication +```basic +TALK "message" Send message to user +TALK TO CHANNEL "name" MESSAGE "x" Send to specific channel +ASK "question" Ask user and wait for response +ASK "question" AS type Ask with validation (email, number, date) +CONFIRM "question" Yes/No question +``` + +### Data Operations +```basic +GET FROM "table" Get all records +GET FROM "table" WHERE field = val Get filtered records +GET FROM "table" WHERE id = "uuid" Get single record +SAVE TO "table" WITH field1, field2 Insert new record +UPDATE "table" SET field = val WHERE id = "uuid" +DELETE FROM "table" WHERE id = "uuid" +``` + +### HTTP Requests +```basic +GET "url" HTTP GET +POST TO "url" WITH data HTTP POST +PUT TO "url" WITH data HTTP PUT +DELETE "url" HTTP DELETE +``` + +### Email +```basic +SEND MAIL TO "email" WITH SUBJECT "subj" BODY "body" +SEND MAIL TO "email" WITH SUBJECT "subj" BODY "body" ATTACHMENT "path" +``` + +### Files +```basic +UPLOAD "local_path" TO "drive_path" +DOWNLOAD "drive_path" TO "local_path" +LIST FILES IN "folder" +DELETE FILE "path" +``` + +### AI/LLM +```basic +result = LLM "prompt" Generate text +result = LLM "prompt" WITH CONTEXT data +image = GENERATE IMAGE "prompt" Generate image +summary = SUMMARIZE document Summarize text +translated = TRANSLATE text TO "es" Translate text +``` + +### Control Flow +```basic +IF condition THEN ... END IF +IF condition THEN ... ELSE ... END IF +FOR EACH item IN collection ... NEXT +FOR i = 1 TO 10 ... NEXT +WHILE condition ... END WHILE +WAIT seconds Pause execution +``` + +### Variables +```basic +SET variable = value +SET variable = ASK "question" +SET variable = GET FROM "table" +variable = expression +``` + +### Dates +```basic +TODAY Current date +NOW Current datetime +YESTERDAY Yesterday's date +LAST_WEEK Last week date range +FORMAT date AS "YYYY-MM-DD" Format date +``` + +--- + +## GENERATED APP STRUCTURE + +When generating an app, create these files: + +``` +{app_name}/ +├── index.html Dashboard/home page +├── styles.css All CSS styles +├── designer.js Designer chat widget (auto-included) +├── {table}.html List page for each table +├── {table}_form.html Create/edit form for each table +└── app.js Optional custom JavaScript +``` + +### Required HTML Head + +Every HTML page MUST include: + +```html + + + + + + Page Title + + + + +``` + +--- + +## RESPONSE FORMAT + +When generating an app, respond with JSON: + +```json +{ + "name": "app-name-lowercase-dashes", + "description": "What this app does", + "domain": "custom|healthcare|sales|inventory|booking|etc", + "tables": [ + { + "name": "table_name", + "fields": [ + {"name": "id", "type": "guid", "nullable": false}, + {"name": "created_at", "type": "datetime", "nullable": false}, + {"name": "updated_at", "type": "datetime", "nullable": false}, + {"name": "field_name", "type": "string", "nullable": true} + ] + } + ], + "pages": [ + { + "filename": "index.html", + "title": "Dashboard", + "html": "complete HTML document" + } + ], + "tools": [ + { + "name": "tool_name", + "triggers": ["phrase1", "phrase2"], + "basic_code": "BASIC code" + } + ], + "schedulers": [ + { + "name": "scheduler_name", + "schedule": "0 9 * * *", + "basic_code": "BASIC code" + } + ], + "css": "complete CSS styles", + "custom_js": "optional JavaScript" +} +``` + +### Field Types + +- `guid` - UUID primary key +- `string` - VARCHAR(255) +- `text` - TEXT (long content) +- `integer` - INT +- `decimal` - DECIMAL(10,2) +- `boolean` - BOOLEAN +- `date` - DATE +- `datetime` - TIMESTAMP +- `json` - JSONB + +--- + +## EXAMPLES + +### Simple Calculator (No Database) + +User: "Create a pink calculator" + +Response: Beautiful calculator UI with pink theme, working JavaScript calculations, no tables needed. + +### CRM Application (With Database) + +User: "Create a CRM for managing customers and deals" + +Response: +- Tables: customers, deals, activities, notes +- Pages: Dashboard with stats, customer list/form, deal pipeline, activity log +- Tools: "add customer", "log activity" +- CSS: Professional business theme + +### Booking System + +User: "Create appointment booking for a dental clinic" + +Response: +- Tables: patients, dentists, appointments, services +- Pages: Calendar view, patient list, appointment form +- Schedulers: Daily reminder emails, weekly availability report +- Tools: "book appointment", "check availability" + +--- + +## IMPORTANT RULES + +1. **Always use HTMX** for API calls - NO fetch() or XMLHttpRequest in HTML +2. **Include designer.js** in all pages for AI modification capability +3. **Make it beautiful** - Use modern CSS, proper spacing, nice colors +4. **Make it functional** - All buttons should work, forms should submit +5. **Use the APIs** - Connect to /api/db/ for data operations +6. **Be complete** - Generate all necessary pages, not just stubs +7. **Match the request** - If user wants pink, make it pink +8. **Tables are optional** - Simple tools don't need database tables \ No newline at end of file diff --git a/src/basic/keywords/app_generator.rs b/src/basic/keywords/app_generator.rs index 38b4a1647..652889cd7 100644 --- a/src/basic/keywords/app_generator.rs +++ b/src/basic/keywords/app_generator.rs @@ -119,11 +119,8 @@ impl AppGenerator { let pages = self.generate_htmx_pages(&structure)?; trace!("Generated {} pages", pages.len()); - // Get bot name for S3 bucket let bot_name = self.get_bot_name(session.bot_id)?; let bucket_name = format!("{}.gbai", bot_name.to_lowercase()); - - // Write to S3 drive: {bucket}/.gbdrive/apps/{app_name}/ let drive_app_path = format!(".gbdrive/apps/{}", structure.name); for page in &pages { @@ -132,6 +129,14 @@ impl AppGenerator { .await?; } + let designer_js = self.generate_designer_js(); + self.write_to_drive( + &bucket_name, + &format!("{}/designer.js", drive_app_path), + &designer_js, + ) + .await?; + let css_content = self.generate_app_css(); self.write_to_drive( &bucket_name, @@ -140,7 +145,6 @@ impl AppGenerator { ) .await?; - // Tools go to {bucket}/.gbdialog/tools/ let tools = self.generate_tools(&structure)?; for tool in &tools { let tool_path = format!(".gbdialog/tools/{}", tool.filename); @@ -148,7 +152,6 @@ impl AppGenerator { .await?; } - // Schedulers go to {bucket}/.gbdialog/schedulers/ let schedulers = self.generate_schedulers(&structure)?; for scheduler in &schedulers { let scheduler_path = format!(".gbdialog/schedulers/{}", scheduler.filename); @@ -156,7 +159,6 @@ impl AppGenerator { .await?; } - // Sync app to SITE_ROOT for serving self.sync_app_to_site_root(&bucket_name, &structure.name, session.bot_id) .await?; @@ -177,40 +179,130 @@ impl AppGenerator { }) } - /// Use LLM to analyze app requirements and generate structure + fn get_platform_capabilities_prompt(&self) -> &'static str { + r##" +GENERAL BOTS APP GENERATOR - PLATFORM CAPABILITIES + +AVAILABLE REST APIs: + +DATABASE API (/api/db/): +- GET /api/db/TABLE - List records (query: limit, offset, order_by, order_dir, search, field=value) +- GET /api/db/TABLE/ID - Get single record +- GET /api/db/TABLE/count - Get record count +- POST /api/db/TABLE - Create record (JSON body) +- PUT /api/db/TABLE/ID - Update record (JSON body) +- DELETE /api/db/TABLE/ID - Delete record + +FILE STORAGE API (/api/drive/): +- GET /api/drive/list?path=/folder - List files +- GET /api/drive/download?path=/file.ext - Download file +- POST /api/drive/upload - Upload (multipart: file, path) +- DELETE /api/drive/delete?path=/file.ext - Delete file + +AUTOTASK API (/api/autotask/): +- POST /api/autotask/create - Create task {"intent": "..."} +- GET /api/autotask/list - List tasks +- GET /api/autotask/stats - Get statistics +- GET /api/autotask/pending - Get pending items + +DESIGNER API (/api/designer/): +- POST /api/designer/modify - AI modify app {"app_name", "current_page", "message"} + +COMMUNICATION APIs: +- POST /api/mail/send - {"to", "subject", "body"} +- POST /api/whatsapp/send - {"to": "+123...", "message"} +- POST /api/llm/generate - {"prompt", "max_tokens"} +- POST /api/llm/image - {"prompt", "size"} + +HTMX ATTRIBUTES: +- hx-get, hx-post, hx-put, hx-delete - HTTP methods +- hx-target="#id" - Where to put response +- hx-swap="innerHTML|outerHTML|beforeend|delete" - How to insert +- hx-trigger="click|submit|load|every 5s|keyup changed delay:300ms" - When to fire +- hx-indicator="#spinner" - Loading indicator +- hx-confirm="Are you sure?" - Confirmation dialog + +BASIC AUTOMATION (.bas files): + +Tools (.gbdialog/tools/*.bas): +HEAR "phrase1", "phrase2" + result = GET FROM "table" + TALK "Response: " + result +END HEAR + +Schedulers (.gbdialog/schedulers/*.bas): +SET SCHEDULE "0 9 * * *" + data = GET FROM "reports" + SEND MAIL TO "email" WITH SUBJECT "Daily" BODY data +END SCHEDULE + +BASIC KEYWORDS: +- TALK "message" - Send message +- ASK "question" - Get user input +- GET FROM "table" WHERE field=val - Query database +- SAVE TO "table" WITH field1, field2 - Insert record +- SEND MAIL TO "x" WITH SUBJECT "y" BODY "z" +- result = LLM "prompt" - AI text generation +- image = GENERATE IMAGE "prompt" - AI image generation + +REQUIRED HTML HEAD (all pages must include): +- link rel="stylesheet" href="styles.css" +- script src="/js/vendor/htmx.min.js" +- script src="designer.js" defer + +FIELD TYPES: guid, string, text, integer, decimal, boolean, date, datetime, json + +RULES: +1. Always use HTMX for API calls - NO fetch() in HTML +2. Include designer.js in ALL pages +3. Make it beautiful and fully functional +4. Tables are optional - simple apps (calculator, timer) dont need them +"## + } + async fn analyze_app_requirements_with_llm( &self, intent: &str, ) -> Result> { + let capabilities = self.get_platform_capabilities_prompt(); + let prompt = format!( - r#"Analyze this user request and design an application structure. + r#"You are an expert app generator for General Bots platform. -User Request: "{intent}" +{capabilities} -Generate a JSON response with the application structure: +USER REQUEST: "{intent}" + +Generate a complete application. For simple apps (calculator, timer, game), you can use empty tables array. +For data apps (CRM, inventory), design appropriate tables. + +Respond with JSON: {{ - "name": "short_app_name (lowercase, no spaces)", - "description": "Brief description of the app", - "domain": "industry domain (healthcare, sales, inventory, booking, etc.)", + "name": "app-name-lowercase-dashes", + "description": "What this app does", + "domain": "custom|healthcare|sales|inventory|booking|etc", "tables": [ {{ "name": "table_name", "fields": [ - {{"name": "field_name", "type": "string|integer|decimal|boolean|date|datetime|text|guid", "nullable": true/false, "reference": "other_table or null"}} + {{"name": "id", "type": "guid", "nullable": false}}, + {{"name": "field_name", "type": "string|integer|decimal|boolean|date|datetime|text", "nullable": true, "reference": "other_table or null"}} ] }} ], - "features": ["crud", "search", "dashboard", "reports", "etc"] + "features": ["list of features"], + "custom_html": "OPTIONAL: For non-CRUD apps like calculators, provide complete HTML here", + "custom_css": "OPTIONAL: Custom CSS styles", + "custom_js": "OPTIONAL: Custom JavaScript" }} -Guidelines: -- Every table should have id (guid), created_at (datetime), updated_at (datetime) -- Use snake_case for table and field names -- Include relationships between tables using _id suffix fields -- Design 2-5 tables based on the request complexity -- Include relevant fields for the domain +IMPORTANT: +- For simple tools (calculator, converter, timer): use custom_html/css/js, tables can be empty [] +- For data apps (CRM, booking): design tables, custom_* fields are optional +- Always include id, created_at, updated_at in tables +- Make it beautiful and fully functional -Respond ONLY with valid JSON."# +Respond with valid JSON only."# ); let response = self.call_llm(&prompt).await?; @@ -1002,6 +1094,7 @@ Respond ONLY with valid JSON."# let _ = writeln!(html, " {}", structure.name); let _ = writeln!(html, " "); let _ = writeln!(html, " "); + let _ = writeln!(html, " "); let _ = writeln!(html, ""); let _ = writeln!(html, ""); let _ = writeln!(html, "
"); @@ -1048,6 +1141,7 @@ Respond ONLY with valid JSON."# let _ = writeln!(html, " {table_name} - List"); let _ = writeln!(html, " "); let _ = writeln!(html, " "); + let _ = writeln!(html, " "); let _ = writeln!(html, ""); let _ = writeln!(html, ""); let _ = writeln!(html, "
"); @@ -1103,10 +1197,11 @@ Respond ONLY with valid JSON."# let _ = writeln!(html, " {table_name} - Form"); let _ = writeln!(html, " "); let _ = writeln!(html, " "); + let _ = writeln!(html, " "); let _ = writeln!(html, ""); let _ = writeln!(html, ""); let _ = writeln!(html, "
"); - let _ = writeln!(html, "

Add {table_name}

"); + let _ = writeln!(html, "

New {table_name}

"); let _ = writeln!( html, " Back to List" @@ -1201,7 +1296,6 @@ input[type="search"] { width: 100%; max-width: 300px; padding: 0.5rem; border: 1 &self, _structure: &AppStructure, ) -> Result, Box> { - // LLM generates actual tool content based on app requirements Ok(Vec::new()) } @@ -1209,11 +1303,92 @@ input[type="search"] { width: 100%; max-width: 300px; padding: 0.5rem; border: 1 &self, _structure: &AppStructure, ) -> Result, Box> { - // LLM generates actual scheduler content based on app requirements Ok(Vec::new()) } - /// Get site path from config + fn generate_designer_js(&self) -> String { + r#"(function() { + const style = document.createElement('style'); + style.textContent = ` + .designer-btn { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; cursor: pointer; box-shadow: 0 4px 20px rgba(102,126,234,0.4); font-size: 24px; z-index: 9999; transition: transform 0.2s, box-shadow 0.2s; } + .designer-btn:hover { transform: scale(1.1); box-shadow: 0 6px 30px rgba(102,126,234,0.6); } + .designer-panel { position: fixed; bottom: 90px; right: 20px; width: 380px; max-height: 500px; background: white; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); z-index: 9998; display: none; flex-direction: column; overflow: hidden; } + .designer-panel.open { display: flex; } + .designer-header { padding: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } + .designer-close { background: none; border: none; color: white; font-size: 20px; cursor: pointer; } + .designer-messages { flex: 1; overflow-y: auto; padding: 16px; max-height: 300px; } + .designer-msg { margin: 8px 0; padding: 10px 14px; border-radius: 12px; max-width: 85%; } + .designer-msg.user { background: #667eea; color: white; margin-left: auto; } + .designer-msg.ai { background: #f0f0f0; color: #333; } + .designer-input { display: flex; padding: 12px; border-top: 1px solid #eee; gap: 8px; } + .designer-input input { flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 20px; outline: none; } + .designer-input button { padding: 10px 16px; background: #667eea; color: white; border: none; border-radius: 20px; cursor: pointer; } + `; + document.head.appendChild(style); + + const btn = document.createElement('button'); + btn.className = 'designer-btn'; + btn.innerHTML = '🎨'; + btn.title = 'Designer AI'; + document.body.appendChild(btn); + + const panel = document.createElement('div'); + panel.className = 'designer-panel'; + panel.innerHTML = ` +
+ 🎨 Designer AI + +
+
+
Hi! I can help you modify this app. What would you like to change?
+
+
+ + +
+ `; + document.body.appendChild(panel); + + btn.onclick = () => panel.classList.toggle('open'); + panel.querySelector('.designer-close').onclick = () => panel.classList.remove('open'); + + const input = panel.querySelector('input'); + const sendBtn = panel.querySelector('.designer-input button'); + const messages = panel.querySelector('.designer-messages'); + + const appName = window.location.pathname.split('/')[2] || 'app'; + const currentPage = window.location.pathname.split('/').pop() || 'index.html'; + + async function sendMessage() { + const msg = input.value.trim(); + if (!msg) return; + + messages.innerHTML += `
${msg}
`; + input.value = ''; + messages.scrollTop = messages.scrollHeight; + + try { + const res = await fetch('/api/designer/modify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_name: appName, current_page: currentPage, message: msg }) + }); + const data = await res.json(); + messages.innerHTML += `
${data.message || 'Done!'}
`; + if (data.success && data.changes && data.changes.length > 0) { + setTimeout(() => location.reload(), 1000); + } + } catch (e) { + messages.innerHTML += `
Sorry, something went wrong. Try again.
`; + } + messages.scrollTop = messages.scrollHeight; + } + + sendBtn.onclick = sendMessage; + input.onkeypress = (e) => { if (e.key === 'Enter') sendMessage(); }; +})();"#.to_string() + } + fn get_site_path(&self) -> String { self.state .config diff --git a/src/basic/keywords/autotask_api.rs b/src/basic/keywords/autotask_api.rs index e65ef4d66..5c6773d9b 100644 --- a/src/basic/keywords/autotask_api.rs +++ b/src/basic/keywords/autotask_api.rs @@ -12,6 +12,9 @@ use axum::{ Json, }; use chrono::Utc; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{Text, Uuid as DieselUuid}; use log::{error, info, trace}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -54,6 +57,33 @@ pub struct IntentResultResponse { pub next_steps: Vec, } +/// Request for one-click create and execute +#[derive(Debug, Deserialize)] +pub struct CreateAndExecuteRequest { + pub intent: String, +} + +/// Response for create and execute - simple status updates +#[derive(Debug, Serialize)] +pub struct CreateAndExecuteResponse { + pub success: bool, + pub task_id: String, + pub status: String, + pub message: String, + pub app_url: Option, + pub created_resources: Vec, + pub pending_items: Vec, + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct PendingItemResponse { + pub id: String, + pub label: String, + pub config_key: String, + pub reason: Option, +} + #[derive(Debug, Serialize)] pub struct CreatedResourceResponse { pub resource_type: String, @@ -259,6 +289,105 @@ pub struct RecommendationResponse { pub action: Option, } +/// Create and execute in one call - no dialogs, just do it +/// POST /api/autotask/create +pub async fn create_and_execute_handler( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + info!( + "Create and execute: {}", + &request.intent[..request.intent.len().min(100)] + ); + + let session = match get_current_session(&state) { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(CreateAndExecuteResponse { + success: false, + task_id: String::new(), + status: "error".to_string(), + message: format!("Authentication error: {}", e), + app_url: None, + created_resources: Vec::new(), + pending_items: Vec::new(), + error: Some(e.to_string()), + }), + ); + } + }; + + // Create task record first + let task_id = Uuid::new_v4(); + if let Err(e) = create_task_record(&state, task_id, &session, &request.intent) { + error!("Failed to create task record: {}", e); + } + + // Update status to running + let _ = update_task_status_db(&state, task_id, "running", None); + + // Use IntentClassifier to classify and process + let classifier = IntentClassifier::new(Arc::clone(&state)); + + match classifier + .classify_and_process(&request.intent, &session) + .await + { + Ok(result) => { + let status = if result.success { + "completed" + } else { + "failed" + }; + let _ = update_task_status_db(&state, task_id, status, result.error.as_deref()); + + // Get any pending items (ASK LATER) + let pending_items = get_pending_items_for_bot(&state, session.bot_id); + + ( + StatusCode::OK, + Json(CreateAndExecuteResponse { + success: result.success, + task_id: task_id.to_string(), + status: status.to_string(), + message: result.message, + app_url: result.app_url, + created_resources: result + .created_resources + .into_iter() + .map(|r| CreatedResourceResponse { + resource_type: r.resource_type, + name: r.name, + path: r.path, + }) + .collect(), + pending_items, + error: result.error, + }), + ) + } + Err(e) => { + let _ = update_task_status_db(&state, task_id, "failed", Some(&e.to_string())); + error!("Failed to process intent: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(CreateAndExecuteResponse { + success: false, + task_id: task_id.to_string(), + status: "failed".to_string(), + message: "Failed to process request".to_string(), + app_url: None, + created_resources: Vec::new(), + pending_items: Vec::new(), + error: Some(e.to_string()), + }), + ) + } + } +} + /// Classify and optionally process an intent /// POST /api/autotask/classify pub async fn classify_intent_handler( @@ -1244,30 +1373,162 @@ fn start_task_execution( } fn list_auto_tasks( - _state: &Arc, - _filter: &str, - _limit: i32, - _offset: i32, + state: &Arc, + filter: &str, + limit: i32, + offset: i32, ) -> Result, Box> { - Ok(Vec::new()) + let mut conn = state.conn.get()?; + + let status_filter = match filter { + "running" => Some("running"), + "pending" => Some("pending"), + "completed" => Some("completed"), + "failed" => Some("failed"), + _ => None, + }; + + #[derive(QueryableByName)] + struct TaskRow { + #[diesel(sql_type = Text)] + id: String, + #[diesel(sql_type = Text)] + title: String, + #[diesel(sql_type = Text)] + intent: String, + #[diesel(sql_type = Text)] + status: String, + #[diesel(sql_type = diesel::sql_types::Float8)] + progress: f64, + } + + let query = if let Some(status) = status_filter { + format!( + "SELECT id::text, title, intent, status, progress FROM auto_tasks WHERE status = '{}' ORDER BY created_at DESC LIMIT {} OFFSET {}", + status, limit, offset + ) + } else { + format!( + "SELECT id::text, title, intent, status, progress FROM auto_tasks ORDER BY created_at DESC LIMIT {} OFFSET {}", + limit, offset + ) + }; + + let rows: Vec = sql_query(&query).get_results(&mut conn).unwrap_or_default(); + + Ok(rows + .into_iter() + .map(|r| AutoTask { + id: r.id, + title: r.title, + intent: r.intent, + status: match r.status.as_str() { + "running" => AutoTaskStatus::Running, + "completed" => AutoTaskStatus::Completed, + "failed" => AutoTaskStatus::Failed, + "paused" => AutoTaskStatus::Paused, + "cancelled" => AutoTaskStatus::Cancelled, + _ => AutoTaskStatus::Draft, + }, + mode: ExecutionMode::FullyAutomatic, + priority: TaskPriority::Medium, + plan_id: None, + basic_program: None, + current_step: 0, + total_steps: 0, + progress: r.progress, + step_results: Vec::new(), + pending_decisions: Vec::new(), + pending_approvals: Vec::new(), + risk_summary: None, + resource_usage: Default::default(), + error: None, + rollback_state: None, + session_id: String::new(), + bot_id: String::new(), + created_by: String::new(), + assigned_to: String::new(), + schedule: None, + tags: Vec::new(), + parent_task_id: None, + subtask_ids: Vec::new(), + depends_on: Vec::new(), + dependents: Vec::new(), + mcp_servers: Vec::new(), + external_apis: Vec::new(), + created_at: Utc::now(), + updated_at: Utc::now(), + started_at: None, + completed_at: None, + estimated_completion: None, + }) + .collect()) } fn get_auto_task_stats( - _state: &Arc, + state: &Arc, ) -> Result> { + let mut conn = state.conn.get()?; + + #[derive(QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let total: i64 = sql_query("SELECT COUNT(*) as count FROM auto_tasks") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let running: i64 = + sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'running'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let pending: i64 = + sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'pending'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let completed: i64 = + sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'completed'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let failed: i64 = sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'failed'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let pending_approval: i64 = + sql_query("SELECT COUNT(*) as count FROM task_approvals WHERE status = 'pending'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + + let pending_decision: i64 = + sql_query("SELECT COUNT(*) as count FROM task_decisions WHERE status = 'pending'") + .get_result::(&mut conn) + .map(|r| r.count) + .unwrap_or(0); + Ok(AutoTaskStatsResponse { - total: 0, - running: 0, - pending: 0, - completed: 0, - failed: 0, - pending_approval: 0, - pending_decision: 0, + total: total as i32, + running: running as i32, + pending: pending as i32, + completed: completed as i32, + failed: failed as i32, + pending_approval: pending_approval as i32, + pending_decision: pending_decision as i32, }) } fn update_task_status( - _state: &Arc, + state: &Arc, task_id: &str, status: AutoTaskStatus, ) -> Result<(), Box> { @@ -1275,9 +1536,126 @@ fn update_task_status( "Updating task status task_id={} status={:?}", task_id, status ); + + let status_str = match status { + AutoTaskStatus::Running => "running", + AutoTaskStatus::Completed => "completed", + AutoTaskStatus::Failed => "failed", + AutoTaskStatus::Paused => "paused", + AutoTaskStatus::Cancelled => "cancelled", + _ => "pending", + }; + + let mut conn = state.conn.get()?; + sql_query("UPDATE auto_tasks SET status = $1, updated_at = NOW() WHERE id = $2::uuid") + .bind::(status_str) + .bind::(task_id) + .execute(&mut conn)?; + Ok(()) } +/// Create task record in database +fn create_task_record( + state: &Arc, + task_id: Uuid, + session: &crate::shared::models::UserSession, + intent: &str, +) -> Result<(), Box> { + let mut conn = state.conn.get()?; + + let title = if intent.len() > 100 { + format!("{}...", &intent[..97]) + } else { + intent.to_string() + }; + + sql_query( + "INSERT INTO auto_tasks (id, bot_id, session_id, title, intent, status, execution_mode, priority, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', 'autonomous', 'normal', 0.0, NOW(), NOW())" + ) + .bind::(task_id) + .bind::(session.bot_id) + .bind::(session.id) + .bind::(&title) + .bind::(intent) + .execute(&mut conn)?; + + Ok(()) +} + +/// Update task status in database +fn update_task_status_db( + state: &Arc, + task_id: Uuid, + status: &str, + error: Option<&str>, +) -> Result<(), Box> { + let mut conn = state.conn.get()?; + + if let Some(err) = error { + sql_query( + "UPDATE auto_tasks SET status = $1, error = $2, updated_at = NOW() WHERE id = $3", + ) + .bind::(status) + .bind::(err) + .bind::(task_id) + .execute(&mut conn)?; + } else { + let completed_at = if status == "completed" || status == "failed" { + ", completed_at = NOW()" + } else { + "" + }; + let query = format!( + "UPDATE auto_tasks SET status = $1, updated_at = NOW(){} WHERE id = $2", + completed_at + ); + sql_query(&query) + .bind::(status) + .bind::(task_id) + .execute(&mut conn)?; + } + + Ok(()) +} + +/// Get pending items (ASK LATER) for a bot +fn get_pending_items_for_bot(state: &Arc, bot_id: Uuid) -> Vec { + let mut conn = match state.conn.get() { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + #[derive(QueryableByName)] + struct PendingRow { + #[diesel(sql_type = Text)] + id: String, + #[diesel(sql_type = Text)] + field_label: String, + #[diesel(sql_type = Text)] + config_key: String, + #[diesel(sql_type = diesel::sql_types::Nullable)] + reason: Option, + } + + let rows: Vec = sql_query( + "SELECT id::text, field_label, config_key, reason FROM pending_info WHERE bot_id = $1 AND is_filled = false" + ) + .bind::(bot_id) + .get_results(&mut conn) + .unwrap_or_default(); + + rows.into_iter() + .map(|r| PendingItemResponse { + id: r.id, + label: r.field_label, + config_key: r.config_key, + reason: r.reason, + }) + .collect() +} + fn simulate_task_execution( _state: &Arc, safety_layer: &SafetyLayer, @@ -1485,6 +1863,112 @@ fn apply_recommendation( ) -> Result<(), Box> { info!("Applying recommendation: {}", rec_id); // TODO: Implement recommendation application logic - // This would modify the execution plan based on the recommendation + Ok(()) +} + +#[derive(Debug, Serialize)] +pub struct PendingItemsResponse { + pub items: Vec, + pub count: usize, +} + +pub async fn get_pending_items_handler(State(state): State>) -> impl IntoResponse { + info!("Getting pending items"); + + let session = match get_current_session(&state) { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(PendingItemsResponse { + items: Vec::new(), + count: 0, + }), + ) + .into_response(); + } + }; + + let items = get_pending_items_for_bot(&state, session.bot_id); + + ( + StatusCode::OK, + Json(PendingItemsResponse { + count: items.len(), + items, + }), + ) + .into_response() +} + +#[derive(Debug, Deserialize)] +pub struct SubmitPendingItemRequest { + pub value: String, +} + +pub async fn submit_pending_item_handler( + State(state): State>, + Path(item_id): Path, + Json(request): Json, +) -> impl IntoResponse { + info!("Submitting pending item {item_id}: {}", request.value); + + let session = match get_current_session(&state) { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, + "error": "Authentication required" + })), + ) + .into_response(); + } + }; + + match resolve_pending_item(&state, &item_id, &request.value, session.bot_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "message": "Pending item resolved" + })), + ) + .into_response(), + Err(e) => { + error!("Failed to resolve pending item {item_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e.to_string() + })), + ) + .into_response() + } + } +} + +fn resolve_pending_item( + state: &Arc, + item_id: &str, + value: &str, + bot_id: Uuid, +) -> Result<(), Box> { + let mut conn = state.conn.get()?; + + let item_uuid = Uuid::parse_str(item_id)?; + + sql_query( + "UPDATE pending_info SET resolved = true, resolved_value = $1, resolved_at = NOW() + WHERE id = $2 AND bot_id = $3", + ) + .bind::(value) + .bind::(item_uuid) + .bind::(bot_id) + .execute(&mut conn)?; + + info!("Resolved pending item {item_id} with value: {value}"); Ok(()) } diff --git a/src/basic/keywords/mod.rs b/src/basic/keywords/mod.rs index b1157878f..4a7b17cd7 100644 --- a/src/basic/keywords/mod.rs +++ b/src/basic/keywords/mod.rs @@ -102,16 +102,18 @@ pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, Simulatio pub use autotask_api::{ apply_recommendation_handler, cancel_task_handler, classify_intent_handler, - compile_intent_handler, execute_plan_handler, execute_task_handler, get_approvals_handler, - get_decisions_handler, get_stats_handler, get_task_logs_handler, list_tasks_handler, - pause_task_handler, resume_task_handler, simulate_plan_handler, simulate_task_handler, - submit_approval_handler, submit_decision_handler, + compile_intent_handler, create_and_execute_handler, execute_plan_handler, execute_task_handler, + get_approvals_handler, get_decisions_handler, get_pending_items_handler, get_stats_handler, + get_task_logs_handler, list_tasks_handler, pause_task_handler, resume_task_handler, + simulate_plan_handler, simulate_task_handler, submit_approval_handler, submit_decision_handler, + submit_pending_item_handler, }; pub fn configure_autotask_routes() -> axum::Router> { use axum::routing::{get, post}; axum::Router::new() + .route("/api/autotask/create", post(create_and_execute_handler)) .route("/api/autotask/classify", post(classify_intent_handler)) .route("/api/autotask/compile", post(compile_intent_handler)) .route("/api/autotask/execute", post(execute_plan_handler)) @@ -150,6 +152,11 @@ pub fn configure_autotask_routes() -> axum::Router Vec { diff --git a/src/designer/mod.rs b/src/designer/mod.rs index 4341ee034..1132a0017 100644 --- a/src/designer/mod.rs +++ b/src/designer/mod.rs @@ -77,6 +77,7 @@ pub fn configure_designer_routes() -> Router> { get(handle_list_dialogs).post(handle_create_dialog), ) .route("/api/designer/dialogs/{id}", get(handle_get_dialog)) + .route("/api/designer/modify", post(handle_designer_modify)) } pub async fn handle_list_files(State(state): State>) -> impl IntoResponse { @@ -742,3 +743,365 @@ fn html_escape(s: &str) -> String { .replace('"', """) .replace('\'', "'") } + +#[derive(Debug, Clone, Deserialize)] +pub struct DesignerModifyRequest { + pub app_name: String, + pub current_page: Option, + pub message: String, + pub context: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DesignerContext { + pub page_html: Option, + pub tables: Option>, + pub recent_changes: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesignerModifyResponse { + pub success: bool, + pub message: String, + pub changes: Vec, + pub suggestions: Vec, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesignerChange { + pub change_type: String, + pub file_path: String, + pub description: String, + pub preview: Option, +} + +pub async fn handle_designer_modify( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let app = &request.app_name; + let msg_preview = &request.message[..request.message.len().min(100)]; + log::info!("Designer modify request for app '{app}': {msg_preview}"); + + let session = match get_designer_session(&state) { + Ok(s) => s, + Err(e) => { + return ( + axum::http::StatusCode::UNAUTHORIZED, + Json(DesignerModifyResponse { + success: false, + message: "Authentication required".to_string(), + changes: Vec::new(), + suggestions: Vec::new(), + error: Some(e.to_string()), + }), + ); + } + }; + + match process_designer_modification(&state, &request, &session).await { + Ok(response) => (axum::http::StatusCode::OK, Json(response)), + Err(e) => { + log::error!("Designer modification failed: {e}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(DesignerModifyResponse { + success: false, + message: "Failed to process modification".to_string(), + changes: Vec::new(), + suggestions: Vec::new(), + error: Some(e.to_string()), + }), + ) + } + } +} + +fn get_designer_session( + state: &AppState, +) -> Result> { + use crate::shared::models::schema::bots::dsl::*; + use crate::shared::models::UserSession; + + let mut conn = state.conn.get()?; + + let bot_result: Result<(Uuid, String), _> = bots.select((id, name)).first(&mut conn); + + match bot_result { + Ok((bot_id_val, _bot_name_val)) => Ok(UserSession { + id: Uuid::new_v4(), + user_id: Uuid::nil(), + bot_id: bot_id_val, + title: "designer".to_string(), + context_data: serde_json::json!({}), + current_tool: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }), + Err(_) => Err("No bot found for designer session".into()), + } +} + +async fn process_designer_modification( + state: &AppState, + request: &DesignerModifyRequest, + session: &crate::shared::models::UserSession, +) -> Result> { + let prompt = build_designer_prompt(request); + let llm_response = call_designer_llm(state, &prompt).await?; + let (changes, message, suggestions) = + parse_and_apply_changes(state, request, &llm_response, session).await?; + + Ok(DesignerModifyResponse { + success: true, + message, + changes, + suggestions, + error: None, + }) +} + +fn build_designer_prompt(request: &DesignerModifyRequest) -> String { + let context_info = request + .context + .as_ref() + .map(|ctx| { + let mut info = String::new(); + if let Some(ref html) = ctx.page_html { + info.push_str(&format!( + "\nCurrent page HTML (first 500 chars):\n{}\n", + &html[..html.len().min(500)] + )); + } + if let Some(ref tables) = ctx.tables { + info.push_str(&format!("\nAvailable tables: {}\n", tables.join(", "))); + } + info + }) + .unwrap_or_default(); + + format!( + r#"You are a Designer AI assistant helping modify an HTMX-based application. + +App Name: {} +Current Page: {} +{} +User Request: "{}" + +Analyze the request and respond with JSON describing the changes needed: +{{ + "understanding": "brief description of what user wants", + "changes": [ + {{ + "type": "modify_html|add_field|remove_field|add_table|modify_style|add_page", + "file": "filename.html or styles.css", + "description": "what this change does", + "code": "the new/modified code snippet" + }} + ], + "message": "friendly response to user explaining what was done", + "suggestions": ["optional follow-up suggestions"] +}} + +Guidelines: +- Use HTMX attributes (hx-get, hx-post, hx-target, hx-swap, hx-trigger) +- Keep styling minimal and consistent +- API endpoints follow pattern: /api/db/{{table_name}} +- Forms should use hx-post for submissions +- Lists should use hx-get with pagination + +Respond with valid JSON only."#, + request.app_name, + request.current_page.as_deref().unwrap_or("index.html"), + context_info, + request.message + ) +} + +async fn call_designer_llm( + _state: &AppState, + prompt: &str, +) -> Result> { + let llm_url = std::env::var("LLM_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "llama3.2".to_string()); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("{}/api/generate", llm_url)) + .json(&serde_json::json!({ + "model": llm_model, + "prompt": prompt, + "stream": false, + "options": { + "temperature": 0.3, + "num_predict": 2000 + } + })) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + return Err(format!("LLM request failed: {status}").into()); + } + + let result: serde_json::Value = response.json().await?; + let response_text = result["response"].as_str().unwrap_or("{}").to_string(); + + let json_text = if response_text.contains("```json") { + response_text + .split("```json") + .nth(1) + .and_then(|s| s.split("```").next()) + .unwrap_or(&response_text) + .trim() + .to_string() + } else if response_text.contains("```") { + response_text + .split("```") + .nth(1) + .unwrap_or(&response_text) + .trim() + .to_string() + } else { + response_text + }; + + Ok(json_text) +} + +async fn parse_and_apply_changes( + state: &AppState, + request: &DesignerModifyRequest, + llm_response: &str, + session: &crate::shared::models::UserSession, +) -> Result<(Vec, String, Vec), Box> { + #[derive(Deserialize)] + struct LlmChangeResponse { + understanding: Option, + changes: Option>, + message: Option, + suggestions: Option>, + } + + #[derive(Deserialize)] + struct LlmChange { + #[serde(rename = "type")] + change_type: String, + file: String, + description: String, + code: Option, + } + + let parsed: LlmChangeResponse = serde_json::from_str(llm_response).unwrap_or(LlmChangeResponse { + understanding: Some("Could not parse LLM response".to_string()), + changes: None, + message: Some("I understood your request but encountered an issue processing it. Could you try rephrasing?".to_string()), + suggestions: Some(vec!["Try being more specific".to_string()]), + }); + + let mut applied_changes = Vec::new(); + + if let Some(changes) = parsed.changes { + for change in changes { + if let Some(ref code) = change.code { + match apply_file_change(state, &request.app_name, &change.file, code, session).await + { + Ok(()) => { + applied_changes.push(DesignerChange { + change_type: change.change_type, + file_path: change.file, + description: change.description, + preview: Some(code[..code.len().min(200)].to_string()), + }); + } + Err(e) => { + let file = &change.file; + log::warn!("Failed to apply change to {file}: {e}"); + } + } + } + } + } + + let message = parsed.message.unwrap_or_else(|| { + if applied_changes.is_empty() { + "I couldn't make any changes. Could you provide more details?".to_string() + } else { + format!( + "Done! I made {} change(s) to your app.", + applied_changes.len() + ) + } + }); + + let suggestions = parsed.suggestions.unwrap_or_default(); + + Ok((applied_changes, message, suggestions)) +} + +async fn apply_file_change( + state: &AppState, + app_name: &str, + file_name: &str, + content: &str, + session: &crate::shared::models::UserSession, +) -> Result<(), Box> { + use crate::shared::models::schema::bots::dsl::*; + + let mut conn = state.conn.get()?; + let bot_name_val: String = bots + .filter(id.eq(session.bot_id)) + .select(name) + .first(&mut conn)?; + + let bucket_name = format!("{}.gbai", bot_name_val.to_lowercase()); + let file_path = format!(".gbdrive/apps/{app_name}/{file_name}"); + + if let Some(ref s3_client) = state.drive { + use aws_sdk_s3::primitives::ByteStream; + + s3_client + .put_object() + .bucket(&bucket_name) + .key(&file_path) + .body(ByteStream::from(content.as_bytes().to_vec())) + .content_type(get_content_type(file_name)) + .send() + .await?; + + log::info!("Designer updated file: s3://{bucket_name}/{file_path}"); + + let site_path = state + .config + .as_ref() + .map(|c| c.site_path.clone()) + .unwrap_or_else(|| "./botserver-stack/sites".to_string()); + + let local_path = format!("{site_path}/{app_name}/{file_name}"); + if let Some(parent) = std::path::Path::new(&local_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&local_path, content)?; + + log::info!("Designer synced to local: {local_path}"); + } + + Ok(()) +} + +fn get_content_type(filename: &str) -> &'static str { + if filename.ends_with(".html") { + "text/html" + } else if filename.ends_with(".css") { + "text/css" + } else if filename.ends_with(".js") { + "application/javascript" + } else if filename.ends_with(".json") { + "application/json" + } else { + "text/plain" + } +} diff --git a/src/main.rs b/src/main.rs index 362bf339b..23722bac7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -212,6 +212,7 @@ async fn run_axum_server( api_router = api_router.merge(botserver::designer::configure_designer_routes()); api_router = api_router.merge(botserver::basic::keywords::configure_db_routes()); api_router = api_router.merge(botserver::basic::keywords::configure_app_server_routes()); + api_router = api_router.merge(botserver::basic::keywords::configure_autotask_routes()); #[cfg(feature = "whatsapp")] {