- Move app_generator, intent_classifier, intent_compiler, autotask_api, designer_ai, ask_later, auto_task, safety_layer to src/auto_task/ - Create auto_task/mod.rs with exports and route configuration - Update imports in moved files - Update main.rs to use auto_task::configure_autotask_routes - Keep table_definition in keywords (shared utility)
1194 lines
40 KiB
Rust
1194 lines
40 KiB
Rust
//! Designer AI Assistant
|
|
//!
|
|
//! An AI-powered assistant that modifies applications through natural conversation.
|
|
//! Based on Chapter 17 - Designer documentation.
|
|
//!
|
|
//! Designer understands context:
|
|
//! - Current app being viewed
|
|
//! - Current page/file active
|
|
//! - Available tables and their schemas
|
|
//! - Existing tools and schedulers
|
|
//!
|
|
//! Designer can modify:
|
|
//! - Styles (colors, layout, fonts)
|
|
//! - HTML pages (forms, lists, buttons)
|
|
//! - Database (add fields, create tables)
|
|
//! - Tools (voice/chat commands)
|
|
//! - Schedulers (automated tasks)
|
|
|
|
use crate::shared::models::UserSession;
|
|
use crate::shared::state::AppState;
|
|
use chrono::{DateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use diesel::sql_query;
|
|
use diesel::sql_types::{Text, Uuid as DieselUuid};
|
|
use log::{info, trace, warn};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
/// Types of modifications Designer can make
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
pub enum ModificationType {
|
|
/// Modify CSS styles
|
|
Style,
|
|
/// Modify HTML structure
|
|
Html,
|
|
/// Add/modify database fields or tables
|
|
Database,
|
|
/// Create/modify voice/chat commands
|
|
Tool,
|
|
/// Create/modify scheduled automations
|
|
Scheduler,
|
|
/// Multiple modifications
|
|
Multiple,
|
|
/// Unknown modification type
|
|
Unknown,
|
|
}
|
|
|
|
impl std::fmt::Display for ModificationType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Style => write!(f, "STYLE"),
|
|
Self::Html => write!(f, "HTML"),
|
|
Self::Database => write!(f, "DATABASE"),
|
|
Self::Tool => write!(f, "TOOL"),
|
|
Self::Scheduler => write!(f, "SCHEDULER"),
|
|
Self::Multiple => write!(f, "MULTIPLE"),
|
|
Self::Unknown => write!(f, "UNKNOWN"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Context about what the user is currently viewing/editing
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct DesignerContext {
|
|
/// Current app name
|
|
pub current_app: Option<String>,
|
|
/// Current page/file being viewed
|
|
pub current_page: Option<String>,
|
|
/// Current element selected (if any)
|
|
pub current_element: Option<String>,
|
|
/// Available tables in this bot's database
|
|
pub available_tables: Vec<TableInfo>,
|
|
/// Available tools
|
|
pub available_tools: Vec<String>,
|
|
/// Available schedulers
|
|
pub available_schedulers: Vec<String>,
|
|
/// Recent changes for undo support
|
|
pub recent_changes: Vec<ChangeRecord>,
|
|
/// Conversation history for context
|
|
pub conversation_history: Vec<ConversationTurn>,
|
|
}
|
|
|
|
/// Summary info about a table
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TableInfo {
|
|
pub name: String,
|
|
pub fields: Vec<String>,
|
|
pub record_count: Option<i64>,
|
|
}
|
|
|
|
/// Record of a change for undo support
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChangeRecord {
|
|
pub id: String,
|
|
pub change_type: ModificationType,
|
|
pub description: String,
|
|
pub file_path: String,
|
|
pub original_content: String,
|
|
pub new_content: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub can_undo: bool,
|
|
}
|
|
|
|
/// A turn in the conversation
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConversationTurn {
|
|
pub role: String, // "user" or "assistant"
|
|
pub content: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
/// Request to modify something
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ModificationRequest {
|
|
pub instruction: String,
|
|
pub context: DesignerContext,
|
|
}
|
|
|
|
/// Result of a modification
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ModificationResult {
|
|
pub success: bool,
|
|
pub modification_type: ModificationType,
|
|
pub message: String,
|
|
pub changes: Vec<FileChange>,
|
|
pub preview: Option<String>,
|
|
pub requires_confirmation: bool,
|
|
pub confirmation_message: Option<String>,
|
|
pub can_undo: bool,
|
|
pub change_id: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// A single file change
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FileChange {
|
|
pub file_path: String,
|
|
pub change_description: String,
|
|
pub before_snippet: Option<String>,
|
|
pub after_snippet: Option<String>,
|
|
pub line_number: Option<i32>,
|
|
}
|
|
|
|
/// Analyzed modification from LLM
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct AnalyzedModification {
|
|
modification_type: ModificationType,
|
|
target_file: String,
|
|
changes: Vec<CodeChange>,
|
|
requires_confirmation: bool,
|
|
confirmation_reason: Option<String>,
|
|
summary: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct CodeChange {
|
|
change_type: String, // "replace", "insert", "delete", "append"
|
|
target: String, // CSS selector, line number, or marker
|
|
content: String,
|
|
context: Option<String>,
|
|
}
|
|
|
|
/// The Designer AI Assistant
|
|
pub struct DesignerAI {
|
|
state: Arc<AppState>,
|
|
}
|
|
|
|
impl DesignerAI {
|
|
pub fn new(state: Arc<AppState>) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
/// Process a modification request
|
|
pub async fn process_request(
|
|
&self,
|
|
request: &ModificationRequest,
|
|
session: &UserSession,
|
|
) -> Result<ModificationResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!(
|
|
"Designer processing request: {}",
|
|
&request.instruction[..request.instruction.len().min(100)]
|
|
);
|
|
|
|
// Analyze what the user wants to modify
|
|
let analysis = self
|
|
.analyze_modification(&request.instruction, &request.context)
|
|
.await?;
|
|
|
|
trace!("Modification analysis: {:?}", analysis.modification_type);
|
|
|
|
// Check if confirmation is needed (destructive operations)
|
|
if analysis.requires_confirmation {
|
|
return Ok(ModificationResult {
|
|
success: false,
|
|
modification_type: analysis.modification_type,
|
|
message: analysis.summary.clone(),
|
|
changes: analysis
|
|
.changes
|
|
.iter()
|
|
.map(|c| FileChange {
|
|
file_path: analysis.target_file.clone(),
|
|
change_description: c.content.clone(),
|
|
before_snippet: c.context.clone(),
|
|
after_snippet: Some(c.content.clone()),
|
|
line_number: None,
|
|
})
|
|
.collect(),
|
|
preview: Some(self.generate_preview(&analysis)),
|
|
requires_confirmation: true,
|
|
confirmation_message: analysis.confirmation_reason,
|
|
can_undo: true,
|
|
change_id: None,
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
// Apply the modification
|
|
self.apply_modification(&analysis, session).await
|
|
}
|
|
|
|
/// Apply a confirmed modification
|
|
pub async fn apply_confirmed_modification(
|
|
&self,
|
|
change_id: &str,
|
|
session: &UserSession,
|
|
) -> Result<ModificationResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
// Retrieve pending change from storage
|
|
let pending = self.get_pending_change(change_id, session)?;
|
|
|
|
match pending {
|
|
Some(analysis) => self.apply_modification(&analysis, session).await,
|
|
None => Ok(ModificationResult {
|
|
success: false,
|
|
modification_type: ModificationType::Unknown,
|
|
message: "Pending change not found or expired".to_string(),
|
|
changes: Vec::new(),
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: false,
|
|
change_id: None,
|
|
error: Some("Change not found".to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Undo a previous change
|
|
pub async fn undo_change(
|
|
&self,
|
|
change_id: &str,
|
|
session: &UserSession,
|
|
) -> Result<ModificationResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
info!("Undoing change: {change_id}");
|
|
|
|
let change_record = self.get_change_record(change_id, session)?;
|
|
|
|
match change_record {
|
|
Some(record) if record.can_undo => {
|
|
// Restore original content
|
|
self.write_file(session.bot_id, &record.file_path, &record.original_content)?;
|
|
|
|
// Remove from change history
|
|
self.remove_change_record(change_id, session)?;
|
|
|
|
Ok(ModificationResult {
|
|
success: true,
|
|
modification_type: record.change_type,
|
|
message: format!("Undone: {}", record.description),
|
|
changes: vec![FileChange {
|
|
file_path: record.file_path,
|
|
change_description: "Restored to previous version".to_string(),
|
|
before_snippet: Some(record.new_content),
|
|
after_snippet: Some(record.original_content),
|
|
line_number: None,
|
|
}],
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: false,
|
|
change_id: Some(change_id.to_string()),
|
|
error: None,
|
|
})
|
|
}
|
|
Some(_) => Ok(ModificationResult {
|
|
success: false,
|
|
modification_type: ModificationType::Unknown,
|
|
message: "This change cannot be undone".to_string(),
|
|
changes: Vec::new(),
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: false,
|
|
change_id: None,
|
|
error: Some("Change is not reversible".to_string()),
|
|
}),
|
|
None => Ok(ModificationResult {
|
|
success: false,
|
|
modification_type: ModificationType::Unknown,
|
|
message: "Change not found".to_string(),
|
|
changes: Vec::new(),
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: false,
|
|
change_id: None,
|
|
error: Some("Change record not found".to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Get current context for the designer
|
|
pub async fn get_context(
|
|
&self,
|
|
session: &UserSession,
|
|
current_app: Option<&str>,
|
|
current_page: Option<&str>,
|
|
) -> Result<DesignerContext, Box<dyn std::error::Error + Send + Sync>> {
|
|
let available_tables = self.get_available_tables(session)?;
|
|
let available_tools = self.get_available_tools(session)?;
|
|
let available_schedulers = self.get_available_schedulers(session)?;
|
|
let recent_changes = self.get_recent_changes(session, 10)?;
|
|
|
|
Ok(DesignerContext {
|
|
current_app: current_app.map(String::from),
|
|
current_page: current_page.map(String::from),
|
|
current_element: None,
|
|
available_tables,
|
|
available_tools,
|
|
available_schedulers,
|
|
recent_changes,
|
|
conversation_history: Vec::new(),
|
|
})
|
|
}
|
|
|
|
// =========================================================================
|
|
// ANALYSIS
|
|
// =========================================================================
|
|
|
|
/// Analyze what modification the user wants
|
|
async fn analyze_modification(
|
|
&self,
|
|
instruction: &str,
|
|
context: &DesignerContext,
|
|
) -> Result<AnalyzedModification, Box<dyn std::error::Error + Send + Sync>> {
|
|
let context_json = serde_json::to_string(context)?;
|
|
|
|
let prompt = format!(
|
|
r#"You are Designer, an AI assistant that modifies applications.
|
|
|
|
USER REQUEST: "{instruction}"
|
|
|
|
CURRENT CONTEXT:
|
|
{context_json}
|
|
|
|
Analyze the request and determine what modifications to make.
|
|
|
|
Response format (JSON only):
|
|
{{
|
|
"modification_type": "STYLE|HTML|DATABASE|TOOL|SCHEDULER|MULTIPLE",
|
|
"target_file": "path/to/file.ext",
|
|
"changes": [
|
|
{{
|
|
"change_type": "replace|insert|delete|append",
|
|
"target": "CSS selector, line marker, or element identifier",
|
|
"content": "new content to add/replace",
|
|
"context": "surrounding code for context"
|
|
}}
|
|
],
|
|
"requires_confirmation": true/false,
|
|
"confirmation_reason": "why confirmation is needed (for destructive operations)",
|
|
"summary": "Brief description of what will change"
|
|
}}
|
|
|
|
Guidelines:
|
|
- STYLE: Changes to CSS files (colors, layout, fonts, spacing)
|
|
- HTML: Changes to HTML structure (forms, buttons, elements)
|
|
- DATABASE: Adding fields to tables.bas or creating new tables
|
|
- TOOL: Creating/modifying .gbdialog/tools/*.bas files
|
|
- SCHEDULER: Creating/modifying .gbdialog/schedulers/*.bas files
|
|
- Require confirmation for: deletions, bulk changes, database schema changes
|
|
- Use the current_app and current_page context to determine which files to modify
|
|
|
|
Respond ONLY with valid JSON."#
|
|
);
|
|
|
|
let response = self.call_llm(&prompt).await?;
|
|
self.parse_analysis_response(&response, instruction)
|
|
}
|
|
|
|
/// Parse LLM analysis response
|
|
fn parse_analysis_response(
|
|
&self,
|
|
response: &str,
|
|
instruction: &str,
|
|
) -> Result<AnalyzedModification, Box<dyn std::error::Error + Send + Sync>> {
|
|
#[derive(Deserialize)]
|
|
struct LlmAnalysis {
|
|
modification_type: String,
|
|
target_file: String,
|
|
changes: Vec<LlmChange>,
|
|
requires_confirmation: Option<bool>,
|
|
confirmation_reason: Option<String>,
|
|
summary: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LlmChange {
|
|
change_type: String,
|
|
target: String,
|
|
content: String,
|
|
context: Option<String>,
|
|
}
|
|
|
|
match serde_json::from_str::<LlmAnalysis>(response) {
|
|
Ok(analysis) => {
|
|
let mod_type = match analysis.modification_type.to_uppercase().as_str() {
|
|
"STYLE" => ModificationType::Style,
|
|
"HTML" => ModificationType::Html,
|
|
"DATABASE" => ModificationType::Database,
|
|
"TOOL" => ModificationType::Tool,
|
|
"SCHEDULER" => ModificationType::Scheduler,
|
|
"MULTIPLE" => ModificationType::Multiple,
|
|
_ => ModificationType::Unknown,
|
|
};
|
|
|
|
Ok(AnalyzedModification {
|
|
modification_type: mod_type,
|
|
target_file: analysis.target_file,
|
|
changes: analysis
|
|
.changes
|
|
.into_iter()
|
|
.map(|c| CodeChange {
|
|
change_type: c.change_type,
|
|
target: c.target,
|
|
content: c.content,
|
|
context: c.context,
|
|
})
|
|
.collect(),
|
|
requires_confirmation: analysis.requires_confirmation.unwrap_or(false),
|
|
confirmation_reason: analysis.confirmation_reason,
|
|
summary: analysis.summary,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to parse LLM analysis: {e}");
|
|
// Fallback to heuristic analysis
|
|
self.analyze_modification_heuristic(instruction)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fallback heuristic analysis
|
|
fn analyze_modification_heuristic(
|
|
&self,
|
|
instruction: &str,
|
|
) -> Result<AnalyzedModification, Box<dyn std::error::Error + Send + Sync>> {
|
|
let lower = instruction.to_lowercase();
|
|
|
|
let (mod_type, target_file) = if lower.contains("color")
|
|
|| lower.contains("background")
|
|
|| lower.contains("font")
|
|
|| lower.contains("style")
|
|
|| lower.contains("css")
|
|
{
|
|
(ModificationType::Style, "styles.css".to_string())
|
|
} else if lower.contains("button")
|
|
|| lower.contains("form")
|
|
|| lower.contains("field")
|
|
|| lower.contains("input")
|
|
|| lower.contains("add")
|
|
{
|
|
(ModificationType::Html, "index.html".to_string())
|
|
} else if lower.contains("table")
|
|
|| lower.contains("column")
|
|
|| lower.contains("database")
|
|
|| lower.contains("schema")
|
|
{
|
|
(ModificationType::Database, "tables.bas".to_string())
|
|
} else if lower.contains("command")
|
|
|| lower.contains("trigger")
|
|
|| lower.contains("when i say")
|
|
{
|
|
(
|
|
ModificationType::Tool,
|
|
".gbdialog/tools/new-tool.bas".to_string(),
|
|
)
|
|
} else if lower.contains("schedule")
|
|
|| lower.contains("every day")
|
|
|| lower.contains("daily")
|
|
|| lower.contains("weekly")
|
|
{
|
|
(
|
|
ModificationType::Scheduler,
|
|
".gbdialog/schedulers/new-scheduler.bas".to_string(),
|
|
)
|
|
} else {
|
|
(ModificationType::Unknown, "".to_string())
|
|
};
|
|
|
|
Ok(AnalyzedModification {
|
|
modification_type: mod_type,
|
|
target_file,
|
|
changes: vec![CodeChange {
|
|
change_type: "manual".to_string(),
|
|
target: instruction.to_string(),
|
|
content: "".to_string(),
|
|
context: None,
|
|
}],
|
|
requires_confirmation: false,
|
|
confirmation_reason: None,
|
|
summary: format!("Process: {}", instruction),
|
|
})
|
|
}
|
|
|
|
// =========================================================================
|
|
// MODIFICATION APPLICATION
|
|
// =========================================================================
|
|
|
|
/// Apply analyzed modification
|
|
async fn apply_modification(
|
|
&self,
|
|
analysis: &AnalyzedModification,
|
|
session: &UserSession,
|
|
) -> Result<ModificationResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let change_id = Uuid::new_v4().to_string();
|
|
|
|
// Read original file content (for undo)
|
|
let original_content = self
|
|
.read_file(session.bot_id, &analysis.target_file)
|
|
.unwrap_or_default();
|
|
|
|
// Generate new content based on modification type
|
|
let new_content = match analysis.modification_type {
|
|
ModificationType::Style => {
|
|
self.apply_style_changes(&original_content, &analysis.changes)
|
|
.await?
|
|
}
|
|
ModificationType::Html => {
|
|
self.apply_html_changes(&original_content, &analysis.changes)
|
|
.await?
|
|
}
|
|
ModificationType::Database => {
|
|
self.apply_database_changes(&original_content, &analysis.changes, session)
|
|
.await?
|
|
}
|
|
ModificationType::Tool => self.generate_tool_file(&analysis.changes, session).await?,
|
|
ModificationType::Scheduler => {
|
|
self.generate_scheduler_file(&analysis.changes, session)
|
|
.await?
|
|
}
|
|
ModificationType::Multiple => {
|
|
// Handle multiple changes sequentially
|
|
self.apply_multiple_changes(analysis, session).await?
|
|
}
|
|
ModificationType::Unknown => {
|
|
return Ok(ModificationResult {
|
|
success: false,
|
|
modification_type: ModificationType::Unknown,
|
|
message: "Could not understand the modification request".to_string(),
|
|
changes: Vec::new(),
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: false,
|
|
change_id: None,
|
|
error: Some("Unknown modification type".to_string()),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Write the new content
|
|
self.write_file(session.bot_id, &analysis.target_file, &new_content)?;
|
|
|
|
// Store change record for undo
|
|
let change_record = ChangeRecord {
|
|
id: change_id.clone(),
|
|
change_type: analysis.modification_type,
|
|
description: analysis.summary.clone(),
|
|
file_path: analysis.target_file.clone(),
|
|
original_content,
|
|
new_content: new_content.clone(),
|
|
timestamp: Utc::now(),
|
|
can_undo: true,
|
|
};
|
|
self.store_change_record(&change_record, session)?;
|
|
|
|
Ok(ModificationResult {
|
|
success: true,
|
|
modification_type: analysis.modification_type,
|
|
message: analysis.summary.clone(),
|
|
changes: analysis
|
|
.changes
|
|
.iter()
|
|
.map(|c| FileChange {
|
|
file_path: analysis.target_file.clone(),
|
|
change_description: c.content.clone(),
|
|
before_snippet: c.context.clone(),
|
|
after_snippet: Some(c.content.clone()),
|
|
line_number: None,
|
|
})
|
|
.collect(),
|
|
preview: None,
|
|
requires_confirmation: false,
|
|
confirmation_message: None,
|
|
can_undo: true,
|
|
change_id: Some(change_id),
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
/// Apply CSS style changes
|
|
async fn apply_style_changes(
|
|
&self,
|
|
original: &str,
|
|
changes: &[CodeChange],
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut content = original.to_string();
|
|
|
|
for change in changes {
|
|
match change.change_type.as_str() {
|
|
"replace" => {
|
|
// Replace a CSS rule
|
|
let pattern = format!(r"{}[\s\S]*?\}}", regex::escape(&change.target));
|
|
if let Ok(re) = regex::Regex::new(&pattern) {
|
|
content = re.replace(&content, &change.content).to_string();
|
|
}
|
|
}
|
|
"append" => {
|
|
// Append new CSS rules
|
|
content.push_str("\n\n");
|
|
content.push_str(&change.content);
|
|
}
|
|
"insert" => {
|
|
// Insert before a target
|
|
if let Some(pos) = content.find(&change.target) {
|
|
content.insert_str(pos, &format!("{}\n\n", change.content));
|
|
}
|
|
}
|
|
_ => {
|
|
content.push_str("\n");
|
|
content.push_str(&change.content);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Apply HTML changes
|
|
async fn apply_html_changes(
|
|
&self,
|
|
original: &str,
|
|
changes: &[CodeChange],
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut content = original.to_string();
|
|
|
|
for change in changes {
|
|
match change.change_type.as_str() {
|
|
"replace" => {
|
|
// Simple string replacement
|
|
content = content.replace(&change.target, &change.content);
|
|
}
|
|
"insert" => {
|
|
// Insert after a target element (e.g., after </form>)
|
|
if let Some(pos) = content.find(&change.target) {
|
|
let insert_pos = pos + change.target.len();
|
|
content.insert_str(insert_pos, &format!("\n{}", change.content));
|
|
}
|
|
}
|
|
"append" => {
|
|
// Append before </body> or at end
|
|
if let Some(pos) = content.find("</body>") {
|
|
content.insert_str(pos, &format!("{}\n", change.content));
|
|
} else {
|
|
content.push_str(&change.content);
|
|
}
|
|
}
|
|
"delete" => {
|
|
content = content.replace(&change.target, "");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Apply database schema changes
|
|
async fn apply_database_changes(
|
|
&self,
|
|
original: &str,
|
|
changes: &[CodeChange],
|
|
session: &UserSession,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut content = original.to_string();
|
|
|
|
for change in changes {
|
|
match change.change_type.as_str() {
|
|
"append_field" => {
|
|
// Add field to existing table
|
|
// Find "END TABLE" for the target table and insert before it
|
|
let end_marker = "END TABLE";
|
|
if let Some(table_pos) = content.find(&change.target) {
|
|
if let Some(end_pos) = content[table_pos..].find(end_marker) {
|
|
let insert_pos = table_pos + end_pos;
|
|
content.insert_str(insert_pos, &format!(" {}\n", change.content));
|
|
}
|
|
}
|
|
}
|
|
"append" => {
|
|
// Add new table definition
|
|
content.push_str("\n\n");
|
|
content.push_str(&change.content);
|
|
}
|
|
_ => {
|
|
content.push_str("\n");
|
|
content.push_str(&change.content);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync schema to database
|
|
self.sync_schema_changes(session)?;
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Generate a tool file
|
|
async fn generate_tool_file(
|
|
&self,
|
|
changes: &[CodeChange],
|
|
_session: &UserSession,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut content = String::new();
|
|
content.push_str(&format!(
|
|
"' Tool generated by Designer\n' Created: {}\n\n",
|
|
Utc::now().format("%Y-%m-%d %H:%M")
|
|
));
|
|
|
|
for change in changes {
|
|
if !change.content.is_empty() {
|
|
content.push_str(&change.content);
|
|
content.push('\n');
|
|
}
|
|
}
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Generate a scheduler file
|
|
async fn generate_scheduler_file(
|
|
&self,
|
|
changes: &[CodeChange],
|
|
_session: &UserSession,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut content = String::new();
|
|
content.push_str(&format!(
|
|
"' Scheduler generated by Designer\n' Created: {}\n\n",
|
|
Utc::now().format("%Y-%m-%d %H:%M")
|
|
));
|
|
|
|
for change in changes {
|
|
if !change.content.is_empty() {
|
|
content.push_str(&change.content);
|
|
content.push('\n');
|
|
}
|
|
}
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Handle multiple changes
|
|
async fn apply_multiple_changes(
|
|
&self,
|
|
_analysis: &AnalyzedModification,
|
|
_session: &UserSession,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
// For multiple changes, each would be applied separately
|
|
// Return summary of changes
|
|
Ok("Multiple changes applied".to_string())
|
|
}
|
|
|
|
/// Generate preview of changes
|
|
fn generate_preview(&self, analysis: &AnalyzedModification) -> String {
|
|
let mut preview = String::new();
|
|
preview.push_str(&format!("File: {}\n\nChanges:\n", analysis.target_file));
|
|
|
|
for (i, change) in analysis.changes.iter().enumerate() {
|
|
preview.push_str(&format!(
|
|
"{}. {} at '{}'\n",
|
|
i + 1,
|
|
change.change_type,
|
|
change.target
|
|
));
|
|
if !change.content.is_empty() {
|
|
preview.push_str(&format!(
|
|
" New content: {}\n",
|
|
&change.content[..change.content.len().min(100)]
|
|
));
|
|
}
|
|
}
|
|
|
|
preview
|
|
}
|
|
|
|
// =========================================================================
|
|
// CONTEXT HELPERS
|
|
// =========================================================================
|
|
|
|
/// Get available tables for the bot
|
|
fn get_available_tables(
|
|
&self,
|
|
_session: &UserSession,
|
|
) -> Result<Vec<TableInfo>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
// Query information_schema for tables in the bot's schema
|
|
let query = format!(
|
|
"SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
AND table_type = 'BASE TABLE'
|
|
LIMIT 50"
|
|
);
|
|
|
|
#[derive(QueryableByName)]
|
|
struct TableRow {
|
|
#[diesel(sql_type = Text)]
|
|
table_name: String,
|
|
}
|
|
|
|
let tables: Vec<TableRow> = sql_query(&query).get_results(&mut conn).unwrap_or_default();
|
|
|
|
Ok(tables
|
|
.into_iter()
|
|
.map(|t| TableInfo {
|
|
name: t.table_name,
|
|
fields: Vec::new(), // Would need separate query
|
|
record_count: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Get available tools
|
|
fn get_available_tools(
|
|
&self,
|
|
session: &UserSession,
|
|
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let site_path = self.get_site_path();
|
|
let tools_path = format!("{}/{}.gbai/.gbdialog/tools", site_path, session.bot_id);
|
|
|
|
let mut tools = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(&tools_path) {
|
|
for entry in entries.flatten() {
|
|
if let Some(name) = entry.file_name().to_str() {
|
|
if name.ends_with(".bas") {
|
|
tools.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(tools)
|
|
}
|
|
|
|
/// Get available schedulers
|
|
fn get_available_schedulers(
|
|
&self,
|
|
session: &UserSession,
|
|
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let site_path = self.get_site_path();
|
|
let schedulers_path = format!("{}/{}.gbai/.gbdialog/schedulers", site_path, session.bot_id);
|
|
|
|
let mut schedulers = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(&schedulers_path) {
|
|
for entry in entries.flatten() {
|
|
if let Some(name) = entry.file_name().to_str() {
|
|
if name.ends_with(".bas") {
|
|
schedulers.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(schedulers)
|
|
}
|
|
|
|
/// Get recent changes for undo
|
|
fn get_recent_changes(
|
|
&self,
|
|
session: &UserSession,
|
|
limit: usize,
|
|
) -> Result<Vec<ChangeRecord>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
#[derive(QueryableByName)]
|
|
struct ChangeRow {
|
|
#[diesel(sql_type = Text)]
|
|
id: String,
|
|
#[diesel(sql_type = Text)]
|
|
change_type: String,
|
|
#[diesel(sql_type = Text)]
|
|
description: String,
|
|
#[diesel(sql_type = Text)]
|
|
file_path: String,
|
|
#[diesel(sql_type = Text)]
|
|
original_content: String,
|
|
#[diesel(sql_type = Text)]
|
|
new_content: String,
|
|
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
let rows: Vec<ChangeRow> = sql_query(
|
|
"SELECT id, change_type, description, file_path, original_content, new_content, created_at
|
|
FROM designer_changes
|
|
WHERE bot_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2",
|
|
)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.bind::<diesel::sql_types::Integer, _>(limit as i32)
|
|
.get_results(&mut conn)
|
|
.unwrap_or_default();
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| ChangeRecord {
|
|
id: r.id,
|
|
change_type: match r.change_type.as_str() {
|
|
"STYLE" => ModificationType::Style,
|
|
"HTML" => ModificationType::Html,
|
|
"DATABASE" => ModificationType::Database,
|
|
"TOOL" => ModificationType::Tool,
|
|
"SCHEDULER" => ModificationType::Scheduler,
|
|
_ => ModificationType::Unknown,
|
|
},
|
|
description: r.description,
|
|
file_path: r.file_path,
|
|
original_content: r.original_content,
|
|
new_content: r.new_content,
|
|
timestamp: r.created_at,
|
|
can_undo: true,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
// =========================================================================
|
|
// FILE OPERATIONS
|
|
// =========================================================================
|
|
|
|
/// Get site path from config
|
|
fn get_site_path(&self) -> String {
|
|
self.state
|
|
.config
|
|
.as_ref()
|
|
.map(|c| c.site_path.clone())
|
|
.unwrap_or_else(|| "./botserver-stack/sites".to_string())
|
|
}
|
|
|
|
/// Read a file from the bot's directory
|
|
fn read_file(
|
|
&self,
|
|
bot_id: Uuid,
|
|
path: &str,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let site_path = self.get_site_path();
|
|
let full_path = format!("{}/{}.gbai/{}", site_path, bot_id, path);
|
|
|
|
match std::fs::read_to_string(&full_path) {
|
|
Ok(content) => Ok(content),
|
|
Err(e) => {
|
|
trace!("Could not read file {}: {}", full_path, e);
|
|
Err(Box::new(e))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write a file to the bot's directory
|
|
fn write_file(
|
|
&self,
|
|
bot_id: Uuid,
|
|
path: &str,
|
|
content: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let site_path = self.get_site_path();
|
|
let full_path = format!("{}/{}.gbai/{}", site_path, bot_id, path);
|
|
|
|
// Create directory if needed
|
|
if let Some(dir) = std::path::Path::new(&full_path).parent() {
|
|
if !dir.exists() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
}
|
|
|
|
std::fs::write(&full_path, content)?;
|
|
info!("Designer wrote file: {}", full_path);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Sync schema changes to database
|
|
fn sync_schema_changes(
|
|
&self,
|
|
_session: &UserSession,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
// This would trigger the TABLE keyword parser to sync
|
|
// For now, just log
|
|
info!("Schema changes need to be synced to database");
|
|
Ok(())
|
|
}
|
|
|
|
// =========================================================================
|
|
// CHANGE RECORD MANAGEMENT
|
|
// =========================================================================
|
|
|
|
/// Store a change record for undo
|
|
fn store_change_record(
|
|
&self,
|
|
record: &ChangeRecord,
|
|
session: &UserSession,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
sql_query(
|
|
"INSERT INTO designer_changes
|
|
(id, bot_id, change_type, description, file_path, original_content, new_content, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (id) DO NOTHING",
|
|
)
|
|
.bind::<DieselUuid, _>(Uuid::parse_str(&record.id)?)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.bind::<Text, _>(record.change_type.to_string())
|
|
.bind::<Text, _>(&record.description)
|
|
.bind::<Text, _>(&record.file_path)
|
|
.bind::<Text, _>(&record.original_content)
|
|
.bind::<Text, _>(&record.new_content)
|
|
.bind::<diesel::sql_types::Timestamptz, _>(record.timestamp)
|
|
.execute(&mut conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get a change record by ID
|
|
fn get_change_record(
|
|
&self,
|
|
change_id: &str,
|
|
session: &UserSession,
|
|
) -> Result<Option<ChangeRecord>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
#[derive(QueryableByName)]
|
|
struct ChangeRow {
|
|
#[diesel(sql_type = Text)]
|
|
id: String,
|
|
#[diesel(sql_type = Text)]
|
|
change_type: String,
|
|
#[diesel(sql_type = Text)]
|
|
description: String,
|
|
#[diesel(sql_type = Text)]
|
|
file_path: String,
|
|
#[diesel(sql_type = Text)]
|
|
original_content: String,
|
|
#[diesel(sql_type = Text)]
|
|
new_content: String,
|
|
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
let result: Option<ChangeRow> = sql_query(
|
|
"SELECT id, change_type, description, file_path, original_content, new_content, created_at
|
|
FROM designer_changes
|
|
WHERE id = $1 AND bot_id = $2",
|
|
)
|
|
.bind::<Text, _>(change_id)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.get_result(&mut conn)
|
|
.optional()?;
|
|
|
|
Ok(result.map(|r| ChangeRecord {
|
|
id: r.id,
|
|
change_type: match r.change_type.as_str() {
|
|
"STYLE" => ModificationType::Style,
|
|
"HTML" => ModificationType::Html,
|
|
"DATABASE" => ModificationType::Database,
|
|
"TOOL" => ModificationType::Tool,
|
|
"SCHEDULER" => ModificationType::Scheduler,
|
|
_ => ModificationType::Unknown,
|
|
},
|
|
description: r.description,
|
|
file_path: r.file_path,
|
|
original_content: r.original_content,
|
|
new_content: r.new_content,
|
|
timestamp: r.created_at,
|
|
can_undo: true,
|
|
}))
|
|
}
|
|
|
|
/// Remove a change record (after undo)
|
|
fn remove_change_record(
|
|
&self,
|
|
change_id: &str,
|
|
session: &UserSession,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
sql_query("DELETE FROM designer_changes WHERE id = $1 AND bot_id = $2")
|
|
.bind::<Text, _>(change_id)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.execute(&mut conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get pending change (for confirmation flow)
|
|
fn get_pending_change(
|
|
&self,
|
|
change_id: &str,
|
|
session: &UserSession,
|
|
) -> Result<Option<AnalyzedModification>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut conn = self.state.conn.get()?;
|
|
|
|
#[derive(QueryableByName)]
|
|
struct PendingRow {
|
|
#[diesel(sql_type = Text)]
|
|
analysis_json: String,
|
|
}
|
|
|
|
let result: Option<PendingRow> = sql_query(
|
|
"SELECT analysis_json FROM designer_pending_changes
|
|
WHERE id = $1 AND bot_id = $2 AND expires_at > NOW()",
|
|
)
|
|
.bind::<Text, _>(change_id)
|
|
.bind::<DieselUuid, _>(session.bot_id)
|
|
.get_result(&mut conn)
|
|
.optional()?;
|
|
|
|
match result {
|
|
Some(row) => {
|
|
let analysis: AnalyzedModification = serde_json::from_str(&row.analysis_json)?;
|
|
Ok(Some(analysis))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// LLM INTEGRATION
|
|
// =========================================================================
|
|
|
|
/// Call LLM for analysis
|
|
async fn call_llm(
|
|
&self,
|
|
prompt: &str,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
trace!("Designer calling LLM");
|
|
|
|
#[cfg(feature = "llm")]
|
|
{
|
|
let config = serde_json::json!({
|
|
"temperature": 0.3,
|
|
"max_tokens": 2000
|
|
});
|
|
let response = self
|
|
.state
|
|
.llm_provider
|
|
.generate(prompt, &config, "gpt-4", "")
|
|
.await?;
|
|
return Ok(response);
|
|
}
|
|
|
|
#[cfg(not(feature = "llm"))]
|
|
{
|
|
warn!("LLM feature not enabled for Designer");
|
|
Ok("{}".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_modification_type_display() {
|
|
assert_eq!(ModificationType::Style.to_string(), "STYLE");
|
|
assert_eq!(ModificationType::Html.to_string(), "HTML");
|
|
assert_eq!(ModificationType::Database.to_string(), "DATABASE");
|
|
assert_eq!(ModificationType::Tool.to_string(), "TOOL");
|
|
assert_eq!(ModificationType::Scheduler.to_string(), "SCHEDULER");
|
|
}
|
|
}
|