feat(autotask): Complete AutoTask flow with LLM-based app generation
- Add comprehensive platform capabilities prompt for LLM (all APIs, HTMX, BASIC) - Add designer.js to all generated pages (dashboard, list, form) - Add /api/autotask/pending endpoint for ASK LATER items - Add /api/designer/modify endpoint for AI-powered app modifications - Wire autotask routes in main.rs - Create APP_GENERATOR_PROMPT.md with full API reference - LLM decides everything - no hardcoded templates
This commit is contained in:
parent
06d0bf1f0a
commit
a384678fb8
6 changed files with 1668 additions and 45 deletions
593
src/basic/keywords/APP_GENERATOR_PROMPT.md
Normal file
593
src/basic/keywords/APP_GENERATOR_PROMPT.md
Normal file
|
|
@ -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
|
||||||
|
<form hx-post="/api/db/users" hx-target="#result">
|
||||||
|
<input name="name" required>
|
||||||
|
<input name="email" type="email" required>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<div id="result"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Lists with Search
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="search"
|
||||||
|
hx-get="/api/db/users"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#user-list"
|
||||||
|
name="search">
|
||||||
|
|
||||||
|
<div id="user-list" hx-get="/api/db/users" hx-trigger="load">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete with Confirmation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button hx-delete="/api/db/users/123"
|
||||||
|
hx-confirm="Delete this user?"
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="delete">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infinite Scroll
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/api/db/posts?offset=0"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="afterend">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polling for Updates
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/api/autotask/stats"
|
||||||
|
hx-trigger="every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Page Title</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<script src="/js/vendor/htmx.min.js"></script>
|
||||||
|
<script src="designer.js" defer></script>
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
@ -119,11 +119,8 @@ impl AppGenerator {
|
||||||
let pages = self.generate_htmx_pages(&structure)?;
|
let pages = self.generate_htmx_pages(&structure)?;
|
||||||
trace!("Generated {} pages", pages.len());
|
trace!("Generated {} pages", pages.len());
|
||||||
|
|
||||||
// Get bot name for S3 bucket
|
|
||||||
let bot_name = self.get_bot_name(session.bot_id)?;
|
let bot_name = self.get_bot_name(session.bot_id)?;
|
||||||
let bucket_name = format!("{}.gbai", bot_name.to_lowercase());
|
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);
|
let drive_app_path = format!(".gbdrive/apps/{}", structure.name);
|
||||||
|
|
||||||
for page in &pages {
|
for page in &pages {
|
||||||
|
|
@ -132,6 +129,14 @@ impl AppGenerator {
|
||||||
.await?;
|
.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();
|
let css_content = self.generate_app_css();
|
||||||
self.write_to_drive(
|
self.write_to_drive(
|
||||||
&bucket_name,
|
&bucket_name,
|
||||||
|
|
@ -140,7 +145,6 @@ impl AppGenerator {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Tools go to {bucket}/.gbdialog/tools/
|
|
||||||
let tools = self.generate_tools(&structure)?;
|
let tools = self.generate_tools(&structure)?;
|
||||||
for tool in &tools {
|
for tool in &tools {
|
||||||
let tool_path = format!(".gbdialog/tools/{}", tool.filename);
|
let tool_path = format!(".gbdialog/tools/{}", tool.filename);
|
||||||
|
|
@ -148,7 +152,6 @@ impl AppGenerator {
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedulers go to {bucket}/.gbdialog/schedulers/
|
|
||||||
let schedulers = self.generate_schedulers(&structure)?;
|
let schedulers = self.generate_schedulers(&structure)?;
|
||||||
for scheduler in &schedulers {
|
for scheduler in &schedulers {
|
||||||
let scheduler_path = format!(".gbdialog/schedulers/{}", scheduler.filename);
|
let scheduler_path = format!(".gbdialog/schedulers/{}", scheduler.filename);
|
||||||
|
|
@ -156,7 +159,6 @@ impl AppGenerator {
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync app to SITE_ROOT for serving
|
|
||||||
self.sync_app_to_site_root(&bucket_name, &structure.name, session.bot_id)
|
self.sync_app_to_site_root(&bucket_name, &structure.name, session.bot_id)
|
||||||
.await?;
|
.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(
|
async fn analyze_app_requirements_with_llm(
|
||||||
&self,
|
&self,
|
||||||
intent: &str,
|
intent: &str,
|
||||||
) -> Result<AppStructure, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<AppStructure, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let capabilities = self.get_platform_capabilities_prompt();
|
||||||
|
|
||||||
let prompt = format!(
|
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)",
|
"name": "app-name-lowercase-dashes",
|
||||||
"description": "Brief description of the app",
|
"description": "What this app does",
|
||||||
"domain": "industry domain (healthcare, sales, inventory, booking, etc.)",
|
"domain": "custom|healthcare|sales|inventory|booking|etc",
|
||||||
"tables": [
|
"tables": [
|
||||||
{{
|
{{
|
||||||
"name": "table_name",
|
"name": "table_name",
|
||||||
"fields": [
|
"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:
|
IMPORTANT:
|
||||||
- Every table should have id (guid), created_at (datetime), updated_at (datetime)
|
- For simple tools (calculator, converter, timer): use custom_html/css/js, tables can be empty []
|
||||||
- Use snake_case for table and field names
|
- For data apps (CRM, booking): design tables, custom_* fields are optional
|
||||||
- Include relationships between tables using _id suffix fields
|
- Always include id, created_at, updated_at in tables
|
||||||
- Design 2-5 tables based on the request complexity
|
- Make it beautiful and fully functional
|
||||||
- Include relevant fields for the domain
|
|
||||||
|
|
||||||
Respond ONLY with valid JSON."#
|
Respond with valid JSON only."#
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = self.call_llm(&prompt).await?;
|
let response = self.call_llm(&prompt).await?;
|
||||||
|
|
@ -1002,6 +1094,7 @@ Respond ONLY with valid JSON."#
|
||||||
let _ = writeln!(html, " <title>{}</title>", structure.name);
|
let _ = writeln!(html, " <title>{}</title>", structure.name);
|
||||||
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
||||||
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
||||||
|
let _ = writeln!(html, " <script src=\"designer.js\" defer></script>");
|
||||||
let _ = writeln!(html, "</head>");
|
let _ = writeln!(html, "</head>");
|
||||||
let _ = writeln!(html, "<body>");
|
let _ = writeln!(html, "<body>");
|
||||||
let _ = writeln!(html, " <header class=\"app-header\">");
|
let _ = writeln!(html, " <header class=\"app-header\">");
|
||||||
|
|
@ -1048,6 +1141,7 @@ Respond ONLY with valid JSON."#
|
||||||
let _ = writeln!(html, " <title>{table_name} - List</title>");
|
let _ = writeln!(html, " <title>{table_name} - List</title>");
|
||||||
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
||||||
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
||||||
|
let _ = writeln!(html, " <script src=\"designer.js\" defer></script>");
|
||||||
let _ = writeln!(html, "</head>");
|
let _ = writeln!(html, "</head>");
|
||||||
let _ = writeln!(html, "<body>");
|
let _ = writeln!(html, "<body>");
|
||||||
let _ = writeln!(html, " <header class=\"page-header\">");
|
let _ = writeln!(html, " <header class=\"page-header\">");
|
||||||
|
|
@ -1103,10 +1197,11 @@ Respond ONLY with valid JSON."#
|
||||||
let _ = writeln!(html, " <title>{table_name} - Form</title>");
|
let _ = writeln!(html, " <title>{table_name} - Form</title>");
|
||||||
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
let _ = writeln!(html, " <link rel=\"stylesheet\" href=\"styles.css\">");
|
||||||
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
let _ = writeln!(html, " <script src=\"/js/vendor/htmx.min.js\"></script>");
|
||||||
|
let _ = writeln!(html, " <script src=\"designer.js\" defer></script>");
|
||||||
let _ = writeln!(html, "</head>");
|
let _ = writeln!(html, "</head>");
|
||||||
let _ = writeln!(html, "<body>");
|
let _ = writeln!(html, "<body>");
|
||||||
let _ = writeln!(html, " <header class=\"page-header\">");
|
let _ = writeln!(html, " <header class=\"page-header\">");
|
||||||
let _ = writeln!(html, " <h1>Add {table_name}</h1>");
|
let _ = writeln!(html, " <h1>New {table_name}</h1>");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
html,
|
html,
|
||||||
" <a href=\"{table_name}.html\" class=\"btn\">Back to List</a>"
|
" <a href=\"{table_name}.html\" class=\"btn\">Back to List</a>"
|
||||||
|
|
@ -1201,7 +1296,6 @@ input[type="search"] { width: 100%; max-width: 300px; padding: 0.5rem; border: 1
|
||||||
&self,
|
&self,
|
||||||
_structure: &AppStructure,
|
_structure: &AppStructure,
|
||||||
) -> Result<Vec<GeneratedScript>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Vec<GeneratedScript>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// LLM generates actual tool content based on app requirements
|
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1209,11 +1303,92 @@ input[type="search"] { width: 100%; max-width: 300px; padding: 0.5rem; border: 1
|
||||||
&self,
|
&self,
|
||||||
_structure: &AppStructure,
|
_structure: &AppStructure,
|
||||||
) -> Result<Vec<GeneratedScript>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Vec<GeneratedScript>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// LLM generates actual scheduler content based on app requirements
|
|
||||||
Ok(Vec::new())
|
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 = `
|
||||||
|
<div class="designer-header">
|
||||||
|
<span>🎨 Designer AI</span>
|
||||||
|
<button class="designer-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="designer-messages">
|
||||||
|
<div class="designer-msg ai">Hi! I can help you modify this app. What would you like to change?</div>
|
||||||
|
</div>
|
||||||
|
<div class="designer-input">
|
||||||
|
<input type="text" placeholder="e.g., Make the header blue..." />
|
||||||
|
<button>Send</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 += `<div class="designer-msg user">${msg}</div>`;
|
||||||
|
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 += `<div class="designer-msg ai">${data.message || 'Done!'}</div>`;
|
||||||
|
if (data.success && data.changes && data.changes.length > 0) {
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
messages.innerHTML += `<div class="designer-msg ai">Sorry, something went wrong. Try again.</div>`;
|
||||||
|
}
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.onclick = sendMessage;
|
||||||
|
input.onkeypress = (e) => { if (e.key === 'Enter') sendMessage(); };
|
||||||
|
})();"#.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn get_site_path(&self) -> String {
|
fn get_site_path(&self) -> String {
|
||||||
self.state
|
self.state
|
||||||
.config
|
.config
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ use axum::{
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_query;
|
||||||
|
use diesel::sql_types::{Text, Uuid as DieselUuid};
|
||||||
use log::{error, info, trace};
|
use log::{error, info, trace};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -54,6 +57,33 @@ pub struct IntentResultResponse {
|
||||||
pub next_steps: Vec<String>,
|
pub next_steps: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub created_resources: Vec<CreatedResourceResponse>,
|
||||||
|
pub pending_items: Vec<PendingItemResponse>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PendingItemResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub config_key: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CreatedResourceResponse {
|
pub struct CreatedResourceResponse {
|
||||||
pub resource_type: String,
|
pub resource_type: String,
|
||||||
|
|
@ -259,6 +289,105 @@ pub struct RecommendationResponse {
|
||||||
pub action: Option<String>,
|
pub action: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Arc<AppState>>,
|
||||||
|
Json(request): Json<CreateAndExecuteRequest>,
|
||||||
|
) -> 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
|
/// Classify and optionally process an intent
|
||||||
/// POST /api/autotask/classify
|
/// POST /api/autotask/classify
|
||||||
pub async fn classify_intent_handler(
|
pub async fn classify_intent_handler(
|
||||||
|
|
@ -1244,30 +1373,162 @@ fn start_task_execution(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_auto_tasks(
|
fn list_auto_tasks(
|
||||||
_state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
_filter: &str,
|
filter: &str,
|
||||||
_limit: i32,
|
limit: i32,
|
||||||
_offset: i32,
|
offset: i32,
|
||||||
) -> Result<Vec<AutoTask>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Vec<AutoTask>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
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<TaskRow> = 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(
|
fn get_auto_task_stats(
|
||||||
_state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
) -> Result<AutoTaskStatsResponse, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<AutoTaskStatsResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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::<CountRow>(&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::<CountRow>(&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::<CountRow>(&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::<CountRow>(&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::<CountRow>(&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::<CountRow>(&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::<CountRow>(&mut conn)
|
||||||
|
.map(|r| r.count)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(AutoTaskStatsResponse {
|
Ok(AutoTaskStatsResponse {
|
||||||
total: 0,
|
total: total as i32,
|
||||||
running: 0,
|
running: running as i32,
|
||||||
pending: 0,
|
pending: pending as i32,
|
||||||
completed: 0,
|
completed: completed as i32,
|
||||||
failed: 0,
|
failed: failed as i32,
|
||||||
pending_approval: 0,
|
pending_approval: pending_approval as i32,
|
||||||
pending_decision: 0,
|
pending_decision: pending_decision as i32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_task_status(
|
fn update_task_status(
|
||||||
_state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
task_id: &str,
|
task_id: &str,
|
||||||
status: AutoTaskStatus,
|
status: AutoTaskStatus,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
|
@ -1275,9 +1536,126 @@ fn update_task_status(
|
||||||
"Updating task status task_id={} status={:?}",
|
"Updating task status task_id={} 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::<Text, _>(status_str)
|
||||||
|
.bind::<Text, _>(task_id)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create task record in database
|
||||||
|
fn create_task_record(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
task_id: Uuid,
|
||||||
|
session: &crate::shared::models::UserSession,
|
||||||
|
intent: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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::<DieselUuid, _>(task_id)
|
||||||
|
.bind::<DieselUuid, _>(session.bot_id)
|
||||||
|
.bind::<DieselUuid, _>(session.id)
|
||||||
|
.bind::<Text, _>(&title)
|
||||||
|
.bind::<Text, _>(intent)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update task status in database
|
||||||
|
fn update_task_status_db(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
task_id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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::<Text, _>(status)
|
||||||
|
.bind::<Text, _>(err)
|
||||||
|
.bind::<DieselUuid, _>(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::<Text, _>(status)
|
||||||
|
.bind::<DieselUuid, _>(task_id)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pending items (ASK LATER) for a bot
|
||||||
|
fn get_pending_items_for_bot(state: &Arc<AppState>, bot_id: Uuid) -> Vec<PendingItemResponse> {
|
||||||
|
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<Text>)]
|
||||||
|
reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<PendingRow> = sql_query(
|
||||||
|
"SELECT id::text, field_label, config_key, reason FROM pending_info WHERE bot_id = $1 AND is_filled = false"
|
||||||
|
)
|
||||||
|
.bind::<DieselUuid, _>(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(
|
fn simulate_task_execution(
|
||||||
_state: &Arc<AppState>,
|
_state: &Arc<AppState>,
|
||||||
safety_layer: &SafetyLayer,
|
safety_layer: &SafetyLayer,
|
||||||
|
|
@ -1485,6 +1863,112 @@ fn apply_recommendation(
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
info!("Applying recommendation: {}", rec_id);
|
info!("Applying recommendation: {}", rec_id);
|
||||||
// TODO: Implement recommendation application logic
|
// 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<PendingItemResponse>,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_pending_items_handler(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>,
|
||||||
|
Path(item_id): Path<String>,
|
||||||
|
Json(request): Json<SubmitPendingItemRequest>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
item_id: &str,
|
||||||
|
value: &str,
|
||||||
|
bot_id: Uuid,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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::<Text, _>(value)
|
||||||
|
.bind::<DieselUuid, _>(item_uuid)
|
||||||
|
.bind::<DieselUuid, _>(bot_id)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
info!("Resolved pending item {item_id} with value: {value}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,16 +102,18 @@ pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, Simulatio
|
||||||
|
|
||||||
pub use autotask_api::{
|
pub use autotask_api::{
|
||||||
apply_recommendation_handler, cancel_task_handler, classify_intent_handler,
|
apply_recommendation_handler, cancel_task_handler, classify_intent_handler,
|
||||||
compile_intent_handler, execute_plan_handler, execute_task_handler, get_approvals_handler,
|
compile_intent_handler, create_and_execute_handler, execute_plan_handler, execute_task_handler,
|
||||||
get_decisions_handler, get_stats_handler, get_task_logs_handler, list_tasks_handler,
|
get_approvals_handler, get_decisions_handler, get_pending_items_handler, get_stats_handler,
|
||||||
pause_task_handler, resume_task_handler, simulate_plan_handler, simulate_task_handler,
|
get_task_logs_handler, list_tasks_handler, pause_task_handler, resume_task_handler,
|
||||||
submit_approval_handler, submit_decision_handler,
|
simulate_plan_handler, simulate_task_handler, submit_approval_handler, submit_decision_handler,
|
||||||
|
submit_pending_item_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared::state::AppState>> {
|
pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared::state::AppState>> {
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
|
.route("/api/autotask/create", post(create_and_execute_handler))
|
||||||
.route("/api/autotask/classify", post(classify_intent_handler))
|
.route("/api/autotask/classify", post(classify_intent_handler))
|
||||||
.route("/api/autotask/compile", post(compile_intent_handler))
|
.route("/api/autotask/compile", post(compile_intent_handler))
|
||||||
.route("/api/autotask/execute", post(execute_plan_handler))
|
.route("/api/autotask/execute", post(execute_plan_handler))
|
||||||
|
|
@ -150,6 +152,11 @@ pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared:
|
||||||
"/api/autotask/recommendations/:rec_id/apply",
|
"/api/autotask/recommendations/:rec_id/apply",
|
||||||
post(apply_recommendation_handler),
|
post(apply_recommendation_handler),
|
||||||
)
|
)
|
||||||
|
.route("/api/autotask/pending", get(get_pending_items_handler))
|
||||||
|
.route(
|
||||||
|
"/api/autotask/pending/:item_id",
|
||||||
|
post(submit_pending_item_handler),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_all_keywords() -> Vec<String> {
|
pub fn get_all_keywords() -> Vec<String> {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ pub fn configure_designer_routes() -> Router<Arc<AppState>> {
|
||||||
get(handle_list_dialogs).post(handle_create_dialog),
|
get(handle_list_dialogs).post(handle_create_dialog),
|
||||||
)
|
)
|
||||||
.route("/api/designer/dialogs/{id}", get(handle_get_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<Arc<AppState>>) -> impl IntoResponse {
|
pub async fn handle_list_files(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
|
@ -742,3 +743,365 @@ fn html_escape(s: &str) -> String {
|
||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
.replace('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DesignerModifyRequest {
|
||||||
|
pub app_name: String,
|
||||||
|
pub current_page: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
pub context: Option<DesignerContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DesignerContext {
|
||||||
|
pub page_html: Option<String>,
|
||||||
|
pub tables: Option<Vec<String>>,
|
||||||
|
pub recent_changes: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DesignerModifyResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub changes: Vec<DesignerChange>,
|
||||||
|
pub suggestions: Vec<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DesignerChange {
|
||||||
|
pub change_type: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub description: String,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_designer_modify(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<DesignerModifyRequest>,
|
||||||
|
) -> 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<crate::shared::models::UserSession, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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<DesignerModifyResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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<DesignerChange>, String, Vec<String>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LlmChangeResponse {
|
||||||
|
understanding: Option<String>,
|
||||||
|
changes: Option<Vec<LlmChange>>,
|
||||||
|
message: Option<String>,
|
||||||
|
suggestions: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LlmChange {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
change_type: String,
|
||||||
|
file: String,
|
||||||
|
description: String,
|
||||||
|
code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::designer::configure_designer_routes());
|
||||||
api_router = api_router.merge(botserver::basic::keywords::configure_db_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_app_server_routes());
|
||||||
|
api_router = api_router.merge(botserver::basic::keywords::configure_autotask_routes());
|
||||||
|
|
||||||
#[cfg(feature = "whatsapp")]
|
#[cfg(feature = "whatsapp")]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue