2131 lines
49 KiB
Markdown
2131 lines
49 KiB
Markdown
|
|
# COMPLETE IMPLEMENTATION GUIDE - Build All 5 Missing Apps
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
This guide provides everything needed to implement the 5 missing backend applications for General Bots Suite. Follow this step-by-step to go from 0 to 100% functionality.
|
||
|
|
|
||
|
|
**Total Time**: 20-25 hours
|
||
|
|
**Difficulty**: Medium
|
||
|
|
**Prerequisites**: Rust, SQL, basic Axum knowledge
|
||
|
|
**Pattern**: HTMX + Rust + Askama (proven by Chat, Drive, Tasks)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 0: Preparation (30 minutes)
|
||
|
|
|
||
|
|
### 1. Understand the Pattern
|
||
|
|
Study how existing working apps are built:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Look at these modules - they show the exact pattern to follow:
|
||
|
|
botserver/src/tasks/mod.rs # CRUD example
|
||
|
|
botserver/src/drive/mod.rs # S3 integration example
|
||
|
|
botserver/src/email/mod.rs # API handler pattern
|
||
|
|
botserver/src/calendar/mod.rs # Complex routes example
|
||
|
|
```
|
||
|
|
|
||
|
|
**Pattern Summary**:
|
||
|
|
1. Create module: `pub mod name;`
|
||
|
|
2. Define request/response structs
|
||
|
|
3. Write handlers: `pub async fn handler() -> Html<String>`
|
||
|
|
4. Use AppState to access DB/Drive/LLM
|
||
|
|
5. Render Askama template
|
||
|
|
6. Return `Ok(Html(template.render()?))`
|
||
|
|
7. Register routes in main.rs with `.merge(name::configure())`
|
||
|
|
|
||
|
|
### 2. Understand the Frontend
|
||
|
|
All HTML files already exist and are HTMX-ready:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# These files are 100% complete, just waiting for backend:
|
||
|
|
botui/ui/suite/analytics/analytics.html # Just needs /api/analytics/*
|
||
|
|
botui/ui/suite/paper/paper.html # Just needs /api/documents/*
|
||
|
|
botui/ui/suite/research/research.html # Just needs /api/kb/search?format=html
|
||
|
|
botui/ui/suite/designer.html # Just needs /api/bots/:id/dialogs/*
|
||
|
|
botui/ui/suite/sources/index.html # Just needs /api/sources/*
|
||
|
|
```
|
||
|
|
|
||
|
|
**Key Point**: Frontend has all HTMX attributes ready. You just implement the endpoints.
|
||
|
|
|
||
|
|
### 3. Understand the Database
|
||
|
|
Tables already exist:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// From botserver/src/schema.rs
|
||
|
|
message_history // timestamp, content, sender, bot_id, user_id
|
||
|
|
sessions // id, bot_id, user_id, created_at, updated_at
|
||
|
|
users // id, name, email
|
||
|
|
bots // id, name, description, active
|
||
|
|
tasks // id, title, status, assigned_to, due_date
|
||
|
|
```
|
||
|
|
|
||
|
|
Use these existing tables - don't create new ones.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 1: Analytics Dashboard (4-6 hours) ← START HERE
|
||
|
|
|
||
|
|
### Why Start Here?
|
||
|
|
- ✅ Quickest to implement (just SQL + templates)
|
||
|
|
- ✅ High user visibility (metrics matter)
|
||
|
|
- ✅ Simplest error handling
|
||
|
|
- ✅ Good proof-of-concept for the pattern
|
||
|
|
|
||
|
|
### Step 1: Create Module Structure
|
||
|
|
|
||
|
|
Create file: `botserver/src/analytics/mod.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! Analytics Module - Bot metrics and dashboards
|
||
|
|
//!
|
||
|
|
//! Provides endpoints for dashboard metrics, session analytics, and performance data.
|
||
|
|
//! All responses are HTML (Askama templates) for HTMX integration.
|
||
|
|
|
||
|
|
use axum::{
|
||
|
|
extract::{Query, State},
|
||
|
|
http::StatusCode,
|
||
|
|
response::Html,
|
||
|
|
routing::get,
|
||
|
|
Router,
|
||
|
|
};
|
||
|
|
use chrono::{DateTime, Duration, Utc};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use crate::core::shared::state::AppState;
|
||
|
|
use crate::schema::*;
|
||
|
|
use diesel::prelude::*;
|
||
|
|
|
||
|
|
// ===== REQUEST/RESPONSE TYPES =====
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct AnalyticsQuery {
|
||
|
|
pub time_range: Option<String>, // "day", "week", "month", "year"
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize, Debug, Clone)]
|
||
|
|
pub struct MetricsData {
|
||
|
|
pub total_messages: i64,
|
||
|
|
pub total_sessions: i64,
|
||
|
|
pub avg_response_time: f64,
|
||
|
|
pub active_users: i64,
|
||
|
|
pub error_count: i64,
|
||
|
|
pub timestamp: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize, Debug)]
|
||
|
|
pub struct SessionAnalytics {
|
||
|
|
pub session_id: String,
|
||
|
|
pub user_id: String,
|
||
|
|
pub messages_count: i64,
|
||
|
|
pub duration_seconds: i64,
|
||
|
|
pub start_time: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== HANDLERS =====
|
||
|
|
|
||
|
|
/// Get dashboard metrics for given time range
|
||
|
|
pub async fn analytics_dashboard(
|
||
|
|
Query(params): Query<AnalyticsQuery>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let time_range = params.time_range.as_deref().unwrap_or("day");
|
||
|
|
|
||
|
|
let mut conn = state
|
||
|
|
.conn
|
||
|
|
.get()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Calculate time interval
|
||
|
|
let cutoff_time = match time_range {
|
||
|
|
"week" => Utc::now() - Duration::days(7),
|
||
|
|
"month" => Utc::now() - Duration::days(30),
|
||
|
|
"year" => Utc::now() - Duration::days(365),
|
||
|
|
_ => Utc::now() - Duration::days(1), // default: day
|
||
|
|
};
|
||
|
|
|
||
|
|
// Query metrics from database
|
||
|
|
let metrics = query_metrics(&mut conn, cutoff_time)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Render template
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "analytics/dashboard.html")]
|
||
|
|
struct DashboardTemplate {
|
||
|
|
metrics: MetricsData,
|
||
|
|
time_range: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = DashboardTemplate {
|
||
|
|
metrics,
|
||
|
|
time_range: time_range.to_string(),
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get session analytics for given time range
|
||
|
|
pub async fn analytics_sessions(
|
||
|
|
Query(params): Query<AnalyticsQuery>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let mut conn = state
|
||
|
|
.conn
|
||
|
|
.get()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let cutoff_time = match params.time_range.as_deref().unwrap_or("day") {
|
||
|
|
"week" => Utc::now() - Duration::days(7),
|
||
|
|
"month" => Utc::now() - Duration::days(30),
|
||
|
|
_ => Utc::now() - Duration::days(1),
|
||
|
|
};
|
||
|
|
|
||
|
|
let sessions = query_sessions(&mut conn, cutoff_time)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "analytics/sessions.html")]
|
||
|
|
struct SessionsTemplate {
|
||
|
|
sessions: Vec<SessionAnalytics>,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = SessionsTemplate { sessions };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== DATABASE QUERIES =====
|
||
|
|
|
||
|
|
fn query_metrics(
|
||
|
|
conn: &mut PgConnection,
|
||
|
|
since: DateTime<Utc>,
|
||
|
|
) -> Result<MetricsData, Box<dyn std::error::Error>> {
|
||
|
|
use crate::schema::message_history::dsl::*;
|
||
|
|
use crate::schema::sessions::dsl as sessions_dsl;
|
||
|
|
use diesel::dsl::*;
|
||
|
|
|
||
|
|
// Count messages
|
||
|
|
let message_count: i64 = message_history
|
||
|
|
.filter(created_at.gt(since))
|
||
|
|
.count()
|
||
|
|
.get_result(conn)?;
|
||
|
|
|
||
|
|
// Count sessions
|
||
|
|
let session_count: i64 = sessions_dsl::sessions
|
||
|
|
.filter(sessions_dsl::created_at.gt(since))
|
||
|
|
.count()
|
||
|
|
.get_result(conn)?;
|
||
|
|
|
||
|
|
// Average response time (in milliseconds)
|
||
|
|
let avg_response: Option<f64> = message_history
|
||
|
|
.filter(created_at.gt(since))
|
||
|
|
.select(avg(response_time))
|
||
|
|
.first(conn)?;
|
||
|
|
|
||
|
|
let avg_response_time = avg_response.unwrap_or(0.0);
|
||
|
|
|
||
|
|
// Count active users (unique user_ids in sessions since cutoff)
|
||
|
|
let active_users: i64 = sessions_dsl::sessions
|
||
|
|
.filter(sessions_dsl::created_at.gt(since))
|
||
|
|
.select(count_distinct(sessions_dsl::user_id))
|
||
|
|
.get_result(conn)?;
|
||
|
|
|
||
|
|
// Count errors
|
||
|
|
let error_count: i64 = message_history
|
||
|
|
.filter(created_at.gt(since))
|
||
|
|
.filter(status.eq("error"))
|
||
|
|
.count()
|
||
|
|
.get_result(conn)?;
|
||
|
|
|
||
|
|
Ok(MetricsData {
|
||
|
|
total_messages: message_count,
|
||
|
|
total_sessions: session_count,
|
||
|
|
avg_response_time,
|
||
|
|
active_users,
|
||
|
|
error_count,
|
||
|
|
timestamp: Utc::now().to_rfc3339(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn query_sessions(
|
||
|
|
conn: &mut PgConnection,
|
||
|
|
since: DateTime<Utc>,
|
||
|
|
) -> Result<Vec<SessionAnalytics>, Box<dyn std::error::Error>> {
|
||
|
|
use crate::schema::sessions::dsl::*;
|
||
|
|
use diesel::sql_types::BigInt;
|
||
|
|
|
||
|
|
let rows = sessions
|
||
|
|
.filter(created_at.gt(since))
|
||
|
|
.select((
|
||
|
|
id,
|
||
|
|
user_id,
|
||
|
|
sql::<BigInt>(
|
||
|
|
"COUNT(*) FILTER (WHERE message_id IS NOT NULL) as message_count"
|
||
|
|
),
|
||
|
|
sql::<BigInt>("EXTRACT(EPOCH FROM (updated_at - created_at)) as duration"),
|
||
|
|
created_at,
|
||
|
|
))
|
||
|
|
.load::<(String, String, i64, i64, DateTime<Utc>)>(conn)?;
|
||
|
|
|
||
|
|
Ok(rows
|
||
|
|
.into_iter()
|
||
|
|
.map(|(session_id, user_id, msg_count, duration, start_time)| SessionAnalytics {
|
||
|
|
session_id,
|
||
|
|
user_id,
|
||
|
|
messages_count: msg_count,
|
||
|
|
duration_seconds: duration,
|
||
|
|
start_time: start_time.to_rfc3339(),
|
||
|
|
})
|
||
|
|
.collect())
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== ROUTE CONFIGURATION =====
|
||
|
|
|
||
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/analytics/dashboard", get(analytics_dashboard))
|
||
|
|
.route("/api/analytics/sessions", get(analytics_sessions))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Create Askama Templates
|
||
|
|
|
||
|
|
Create file: `botserver/templates/analytics/dashboard.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="analytics-dashboard">
|
||
|
|
<div class="metrics-grid">
|
||
|
|
<div class="metric-card">
|
||
|
|
<span class="metric-label">Total Messages</span>
|
||
|
|
<span class="metric-value">{{ metrics.total_messages }}</span>
|
||
|
|
<span class="metric-unit">messages</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="metric-card">
|
||
|
|
<span class="metric-label">Active Sessions</span>
|
||
|
|
<span class="metric-value">{{ metrics.total_sessions }}</span>
|
||
|
|
<span class="metric-unit">sessions</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="metric-card">
|
||
|
|
<span class="metric-label">Avg Response Time</span>
|
||
|
|
<span class="metric-value">{{ metrics.avg_response_time | round(2) }}</span>
|
||
|
|
<span class="metric-unit">ms</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="metric-card">
|
||
|
|
<span class="metric-label">Active Users</span>
|
||
|
|
<span class="metric-value">{{ metrics.active_users }}</span>
|
||
|
|
<span class="metric-unit">users</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="metric-card">
|
||
|
|
<span class="metric-label">Errors</span>
|
||
|
|
<span class="metric-value">{{ metrics.error_count }}</span>
|
||
|
|
<span class="metric-unit">errors</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="analytics-footer">
|
||
|
|
<small>Updated: {{ metrics.timestamp }}</small>
|
||
|
|
<small>Period: {{ time_range }}</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.analytics-dashboard {
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metrics-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
|
|
gap: 15px;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-card {
|
||
|
|
padding: 15px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 8px;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-label {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
font-weight: 600;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-value {
|
||
|
|
font-size: 28px;
|
||
|
|
font-weight: bold;
|
||
|
|
color: var(--accent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-unit {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.analytics-footer {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
border-top: 1px solid var(--border-color);
|
||
|
|
padding-top: 15px;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create file: `botserver/templates/analytics/sessions.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="sessions-table">
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Session ID</th>
|
||
|
|
<th>User ID</th>
|
||
|
|
<th>Messages</th>
|
||
|
|
<th>Duration</th>
|
||
|
|
<th>Started</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for session in sessions %}
|
||
|
|
<tr>
|
||
|
|
<td><code>{{ session.session_id }}</code></td>
|
||
|
|
<td>{{ session.user_id }}</td>
|
||
|
|
<td>{{ session.messages_count }}</td>
|
||
|
|
<td>{{ session.duration_seconds }}s</td>
|
||
|
|
<td>{{ session.start_time }}</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.sessions-table {
|
||
|
|
overflow-x: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
}
|
||
|
|
|
||
|
|
th {
|
||
|
|
text-align: left;
|
||
|
|
padding: 12px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
td {
|
||
|
|
padding: 12px;
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
tr:hover {
|
||
|
|
background-color: var(--bg-hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
code {
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
padding: 2px 6px;
|
||
|
|
border-radius: 3px;
|
||
|
|
font-family: monospace;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Register in main.rs
|
||
|
|
|
||
|
|
Add to `botserver/src/main.rs` in the module declarations:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Add near top with other mod declarations:
|
||
|
|
pub mod analytics;
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to router setup (around line 169):
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Add after email routes
|
||
|
|
#[cfg(feature = "analytics")]
|
||
|
|
{
|
||
|
|
api_router = api_router.merge(botserver::analytics::configure());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Or always enable (remove cfg):
|
||
|
|
api_router = api_router.merge(botserver::analytics::configure());
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to Cargo.toml features (optional):
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[features]
|
||
|
|
analytics = []
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Update URL Constants
|
||
|
|
|
||
|
|
Add to `botserver/src/core/urls.rs`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Add in ApiUrls impl block:
|
||
|
|
pub const ANALYTICS_DASHBOARD: &'static str = "/api/analytics/dashboard";
|
||
|
|
pub const ANALYTICS_SESSIONS: &'static str = "/api/analytics/sessions";
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 5: Test Locally
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Build
|
||
|
|
cd botserver
|
||
|
|
cargo build
|
||
|
|
|
||
|
|
# Test endpoint
|
||
|
|
curl -X GET "http://localhost:3000/api/analytics/dashboard?time_range=day"
|
||
|
|
|
||
|
|
# Should return HTML, not JSON
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 6: Verify in Browser
|
||
|
|
|
||
|
|
1. Open http://localhost:3000
|
||
|
|
2. Click "Analytics" in app menu
|
||
|
|
3. See metrics populate
|
||
|
|
4. Check Network tab - should see `/api/analytics/dashboard` request
|
||
|
|
5. Response should be HTML
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 2: Paper Documents (2-3 hours)
|
||
|
|
|
||
|
|
### Step 1: Create Module
|
||
|
|
|
||
|
|
Create file: `botserver/src/documents/mod.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! Documents Module - Document creation and management
|
||
|
|
//!
|
||
|
|
//! Provides endpoints for CRUD operations on documents.
|
||
|
|
//! Documents are stored in S3 Drive under .gbdocs/ folder.
|
||
|
|
|
||
|
|
use axum::{
|
||
|
|
extract::{Path, Query, State},
|
||
|
|
http::StatusCode,
|
||
|
|
response::Html,
|
||
|
|
routing::{delete, get, post, put},
|
||
|
|
Json, Router,
|
||
|
|
};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use uuid::Uuid;
|
||
|
|
use crate::core::shared::state::AppState;
|
||
|
|
|
||
|
|
// ===== REQUEST/RESPONSE TYPES =====
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct CreateDocumentRequest {
|
||
|
|
pub title: String,
|
||
|
|
pub content: String,
|
||
|
|
pub doc_type: Option<String>, // "draft", "note", "template"
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize, Clone)]
|
||
|
|
pub struct DocumentResponse {
|
||
|
|
pub id: String,
|
||
|
|
pub title: String,
|
||
|
|
pub content: String,
|
||
|
|
pub doc_type: String,
|
||
|
|
pub created_at: String,
|
||
|
|
pub updated_at: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct UpdateDocumentRequest {
|
||
|
|
pub title: Option<String>,
|
||
|
|
pub content: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== HANDLERS =====
|
||
|
|
|
||
|
|
/// Create new document
|
||
|
|
pub async fn create_document(
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
Json(req): Json<CreateDocumentRequest>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let doc_id = Uuid::new_v4().to_string();
|
||
|
|
let now = chrono::Utc::now().to_rfc3339();
|
||
|
|
let doc_type = req.doc_type.unwrap_or_else(|| "draft".to_string());
|
||
|
|
|
||
|
|
// Get Drive client
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Store in Drive
|
||
|
|
let bucket = "general-bots-documents";
|
||
|
|
let key = format!(".gbdocs/{}/document.json", doc_id);
|
||
|
|
|
||
|
|
let document = DocumentResponse {
|
||
|
|
id: doc_id.clone(),
|
||
|
|
title: req.title.clone(),
|
||
|
|
content: req.content.clone(),
|
||
|
|
doc_type,
|
||
|
|
created_at: now.clone(),
|
||
|
|
updated_at: now,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Serialize and upload
|
||
|
|
let json = serde_json::to_string(&document)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
drive
|
||
|
|
.put_object(bucket, &key, json.into_bytes())
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Return success HTML
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "documents/created.html")]
|
||
|
|
struct CreatedTemplate {
|
||
|
|
doc_id: String,
|
||
|
|
title: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = CreatedTemplate {
|
||
|
|
doc_id,
|
||
|
|
title: req.title,
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get all documents
|
||
|
|
pub async fn list_documents(
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = "general-bots-documents";
|
||
|
|
|
||
|
|
// List objects in .gbdocs/ folder
|
||
|
|
let objects = drive
|
||
|
|
.list_objects(bucket, ".gbdocs/")
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Load and parse each document
|
||
|
|
let mut documents = Vec::new();
|
||
|
|
for obj in objects {
|
||
|
|
if obj.key.ends_with("document.json") {
|
||
|
|
if let Ok(content) = drive
|
||
|
|
.get_object(bucket, &obj.key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||
|
|
{
|
||
|
|
if let Ok(doc) = serde_json::from_slice::<DocumentResponse>(&content) {
|
||
|
|
documents.push(doc);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "documents/list.html")]
|
||
|
|
struct ListTemplate {
|
||
|
|
documents: Vec<DocumentResponse>,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = ListTemplate { documents };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get single document
|
||
|
|
pub async fn get_document(
|
||
|
|
Path(doc_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = "general-bots-documents";
|
||
|
|
let key = format!(".gbdocs/{}/document.json", doc_id);
|
||
|
|
|
||
|
|
let content = drive
|
||
|
|
.get_object(bucket, &key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||
|
|
|
||
|
|
let document = serde_json::from_slice::<DocumentResponse>(&content)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "documents/detail.html")]
|
||
|
|
struct DetailTemplate {
|
||
|
|
document: DocumentResponse,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = DetailTemplate { document };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Update document
|
||
|
|
pub async fn update_document(
|
||
|
|
Path(doc_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
Json(req): Json<UpdateDocumentRequest>,
|
||
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = "general-bots-documents";
|
||
|
|
let key = format!(".gbdocs/{}/document.json", doc_id);
|
||
|
|
|
||
|
|
// Get existing document
|
||
|
|
let content = drive
|
||
|
|
.get_object(bucket, &key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||
|
|
|
||
|
|
let mut document = serde_json::from_slice::<DocumentResponse>(&content)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
// Update fields
|
||
|
|
if let Some(title) = req.title {
|
||
|
|
document.title = title;
|
||
|
|
}
|
||
|
|
if let Some(content) = req.content {
|
||
|
|
document.content = content;
|
||
|
|
}
|
||
|
|
document.updated_at = chrono::Utc::now().to_rfc3339();
|
||
|
|
|
||
|
|
// Save updated document
|
||
|
|
let json = serde_json::to_string(&document)
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
drive
|
||
|
|
.put_object(bucket, &key, json.into_bytes())
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
Ok(Json(serde_json::json!({
|
||
|
|
"success": true,
|
||
|
|
"document": document
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Delete document
|
||
|
|
pub async fn delete_document(
|
||
|
|
Path(doc_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> StatusCode {
|
||
|
|
let drive = match state.drive.as_ref() {
|
||
|
|
Some(d) => d,
|
||
|
|
None => return StatusCode::INTERNAL_SERVER_ERROR,
|
||
|
|
};
|
||
|
|
|
||
|
|
let bucket = "general-bots-documents";
|
||
|
|
let key = format!(".gbdocs/{}/document.json", doc_id);
|
||
|
|
|
||
|
|
match drive.delete_object(bucket, &key).await {
|
||
|
|
Ok(_) => StatusCode::NO_CONTENT,
|
||
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== ROUTE CONFIGURATION =====
|
||
|
|
|
||
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/documents", post(create_document).get(list_documents))
|
||
|
|
.route(
|
||
|
|
"/api/documents/:id",
|
||
|
|
get(get_document)
|
||
|
|
.put(update_document)
|
||
|
|
.delete(delete_document),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Create Templates
|
||
|
|
|
||
|
|
Create file: `botserver/templates/documents/list.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="documents-container">
|
||
|
|
<div class="documents-header">
|
||
|
|
<h2>Documents</h2>
|
||
|
|
<button hx-get="/api/documents/new" hx-target="#document-editor">
|
||
|
|
+ New Document
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="documents-grid">
|
||
|
|
{% for doc in documents %}
|
||
|
|
<div class="document-card" hx-get="/api/documents/{{ doc.id }}" hx-target="#document-viewer" hx-swap="innerHTML">
|
||
|
|
<h3>{{ doc.title }}</h3>
|
||
|
|
<p class="preview">{{ doc.content | truncate(100) }}</p>
|
||
|
|
<div class="card-meta">
|
||
|
|
<span class="type">{{ doc.doc_type }}</span>
|
||
|
|
<span class="date">{{ doc.updated_at | truncate(10) }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endfor %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.documents-container {
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.documents-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
padding-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.documents-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
|
|
gap: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-card {
|
||
|
|
padding: 15px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 8px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-card:hover {
|
||
|
|
background: var(--bg-hover);
|
||
|
|
transform: translateY(-2px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-card h3 {
|
||
|
|
margin: 0 0 10px 0;
|
||
|
|
font-size: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.preview {
|
||
|
|
color: var(--text-secondary);
|
||
|
|
font-size: 13px;
|
||
|
|
margin: 0 0 10px 0;
|
||
|
|
line-height: 1.4;
|
||
|
|
}
|
||
|
|
|
||
|
|
.card-meta {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.type {
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
padding: 2px 6px;
|
||
|
|
border-radius: 3px;
|
||
|
|
}
|
||
|
|
|
||
|
|
button {
|
||
|
|
padding: 8px 15px;
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 6px;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
button:hover {
|
||
|
|
opacity: 0.9;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create file: `botserver/templates/documents/detail.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="document-viewer">
|
||
|
|
<div class="document-header">
|
||
|
|
<h1 id="doc-title" contenteditable="true">{{ document.title }}</h1>
|
||
|
|
<div class="document-actions">
|
||
|
|
<button hx-delete="/api/documents/{{ document.id }}" hx-confirm="Delete this document?">
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="doc-content" contenteditable="true" class="document-content">
|
||
|
|
{{ document.content }}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="document-footer">
|
||
|
|
<small>Created: {{ document.created_at }}</small>
|
||
|
|
<small>Updated: {{ document.updated_at }}</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.document-viewer {
|
||
|
|
padding: 30px;
|
||
|
|
max-width: 900px;
|
||
|
|
margin: 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 30px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
padding-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
#doc-title {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 32px;
|
||
|
|
outline: none;
|
||
|
|
border: 2px solid transparent;
|
||
|
|
padding: 5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
#doc-title:focus {
|
||
|
|
border: 2px solid var(--accent);
|
||
|
|
border-radius: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-content {
|
||
|
|
min-height: 400px;
|
||
|
|
padding: 15px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 6px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
outline: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-content:focus {
|
||
|
|
border: 2px solid var(--accent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.document-footer {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
margin-top: 20px;
|
||
|
|
padding-top: 15px;
|
||
|
|
border-top: 1px solid var(--border-color);
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
button {
|
||
|
|
padding: 8px 15px;
|
||
|
|
background: #dc3545;
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 4px;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
button:hover {
|
||
|
|
opacity: 0.9;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Register in main.rs
|
||
|
|
|
||
|
|
Add module:
|
||
|
|
```rust
|
||
|
|
pub mod documents;
|
||
|
|
```
|
||
|
|
|
||
|
|
Add routes:
|
||
|
|
```rust
|
||
|
|
api_router = api_router.merge(botserver::documents::configure());
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Test
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cargo build
|
||
|
|
curl -X POST "http://localhost:3000/api/documents" \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{
|
||
|
|
"title": "My First Document",
|
||
|
|
"content": "Hello world",
|
||
|
|
"doc_type": "draft"
|
||
|
|
}'
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 3: Research HTML Integration (1-2 hours)
|
||
|
|
|
||
|
|
### Step 1: Find Existing KB Search
|
||
|
|
|
||
|
|
Locate: `botserver/src/core/kb/mod.rs` (find the search function)
|
||
|
|
|
||
|
|
### Step 2: Update Handler
|
||
|
|
|
||
|
|
Change from returning `Json` to `Html`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// OLD:
|
||
|
|
pub async fn search_kb(...) -> Json<SearchResults> { ... }
|
||
|
|
|
||
|
|
// NEW:
|
||
|
|
pub async fn search_kb(
|
||
|
|
Query(params): Query<SearchQuery>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
// Query logic stays the same
|
||
|
|
let results = perform_search(¶ms, state).await?;
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "kb/search_results.html")]
|
||
|
|
struct SearchResultsTemplate {
|
||
|
|
results: Vec<SearchResult>,
|
||
|
|
query: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = SearchResultsTemplate {
|
||
|
|
results,
|
||
|
|
query: params.q,
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Html(template.render()?))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Create Template
|
||
|
|
|
||
|
|
Create file: `botserver/templates/kb/search_results.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="search-results">
|
||
|
|
{% if results.is_empty() %}
|
||
|
|
<div class="no-results">
|
||
|
|
<p>No results found for "{{ query }}"</p>
|
||
|
|
</div>
|
||
|
|
{% else %}
|
||
|
|
<div class="results-count">
|
||
|
|
<span>{{ results | length }} result{{ results | length != 1 | ternary("s", "") }}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{% for result in results %}
|
||
|
|
<div class="result-item" hx-get="/api/kb/{{ result.id }}" hx-target="#kb-detail">
|
||
|
|
<h3>{{ result.title }}</h3>
|
||
|
|
<p class="snippet">{{ result.snippet }}</p>
|
||
|
|
<div class="result-meta">
|
||
|
|
<span class="score">Relevance: {{ result.score | round(2) }}</span>
|
||
|
|
<span class="source">{{ result.source }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endfor %}
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.search-results {
|
||
|
|
padding: 15px 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-results {
|
||
|
|
text-align: center;
|
||
|
|
padding: 30px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.results-count {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
margin-bottom: 15px;
|
||
|
|
padding-left: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.result-item {
|
||
|
|
padding: 12px;
|
||
|
|
margin-bottom: 10px;
|
||
|
|
border-left: 3px solid var(--accent);
|
||
|
|
border-radius: 4px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.result-item:hover {
|
||
|
|
background: var(--bg-hover);
|
||
|
|
transform: translateX(5px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.result-item h3 {
|
||
|
|
margin: 0 0 5px 0;
|
||
|
|
font-size: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.snippet {
|
||
|
|
margin: 0 0 8px 0;
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
line-height: 1.4;
|
||
|
|
}
|
||
|
|
|
||
|
|
.result-meta {
|
||
|
|
display: flex;
|
||
|
|
gap: 15px;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.score {
|
||
|
|
color: var(--accent);
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Test
|
||
|
|
|
||
|
|
Frontend already has HTMX attributes ready, so it should just work:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# In browser, go to Research app
|
||
|
|
# Type search query
|
||
|
|
# Should see HTML results instead of JSON errors
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 4: Sources Template Manager (2-3 hours)
|
||
|
|
|
||
|
|
### Step 1: Create Module
|
||
|
|
|
||
|
|
Create file: `botserver/src/sources/mod.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! Sources Module - Templates and prompt library
|
||
|
|
//!
|
||
|
|
//! Provides endpoints for browsing and managing source templates.
|
||
|
|
|
||
|
|
use axum::{
|
||
|
|
extract::{Path, Query, State},
|
||
|
|
http::StatusCode,
|
||
|
|
response::Html,
|
||
|
|
routing::get,
|
||
|
|
Json, Router,
|
||
|
|
};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use crate::core::shared::state::AppState;
|
||
|
|
|
||
|
|
// ===== TYPES =====
|
||
|
|
|
||
|
|
#[derive(Serialize, Clone)]
|
||
|
|
pub struct Source {
|
||
|
|
pub id: String,
|
||
|
|
pub name: String,
|
||
|
|
pub description: String,
|
||
|
|
pub category: String,
|
||
|
|
pub tags: Vec<String>,
|
||
|
|
pub downloads: i32,
|
||
|
|
pub rating: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct SourcesQuery {
|
||
|
|
pub category: Option<String>,
|
||
|
|
pub limit: Option<i32>,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== HANDLERS =====
|
||
|
|
|
||
|
|
pub async fn list_sources(
|
||
|
|
Query(params): Query<SourcesQuery>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = "general-bots-templates";
|
||
|
|
|
||
|
|
// List templates from Drive
|
||
|
|
let objects = drive
|
||
|
|
.list_objects(bucket, ".gbai/templates/")
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let mut sources = Vec::new();
|
||
|
|
|
||
|
|
// Parse each template file
|
||
|
|
for obj in objects {
|
||
|
|
if obj.key.ends_with(".bas") {
|
||
|
|
if let Ok(content) = drive
|
||
|
|
.get_object(bucket, &obj.key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||
|
|
{
|
||
|
|
if let Ok(text) = String::from_utf8(content) {
|
||
|
|
// Parse metadata from template
|
||
|
|
let source = parse_template_metadata(&text, &obj.key);
|
||
|
|
sources.push(source);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter by category if provided
|
||
|
|
if let Some(category) = ¶ms.category {
|
||
|
|
if category != "all" {
|
||
|
|
sources.retain(|s| s.category == *category);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Limit results
|
||
|
|
if let Some(limit) = params.limit {
|
||
|
|
sources.truncate(limit as usize);
|
||
|
|
}
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "sources/grid.html")]
|
||
|
|
struct SourcesTemplate {
|
||
|
|
sources: Vec<Source>,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = SourcesTemplate { sources };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn get_source(
|
||
|
|
Path(source_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = "general-bots-templates";
|
||
|
|
let key = format!(".gbai/templates/{}.bas", source_id);
|
||
|
|
|
||
|
|
let content = drive
|
||
|
|
.get_object(bucket, &key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||
|
|
|
||
|
|
let text =
|
||
|
|
String::from_utf8(content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let source = parse_template_metadata(&text, &key);
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "sources/detail.html")]
|
||
|
|
struct DetailTemplate {
|
||
|
|
source: Source,
|
||
|
|
content: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = DetailTemplate {
|
||
|
|
source,
|
||
|
|
content: text,
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== HELPERS =====
|
||
|
|
|
||
|
|
fn parse_template_metadata(content: &str, path: &str) -> Source {
|
||
|
|
// Extract name from path
|
||
|
|
let name = path
|
||
|
|
.split('/')
|
||
|
|
.last()
|
||
|
|
.unwrap_or("unknown")
|
||
|
|
.trim_end_matches(".bas")
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Parse description from first line comment if exists
|
||
|
|
let description = content
|
||
|
|
.lines()
|
||
|
|
.find(|line| line.starts_with("'"))
|
||
|
|
.map(|line| line.trim_start_matches('\'').trim().to_string())
|
||
|
|
.unwrap_or_else(|| "No description".to_string());
|
||
|
|
|
||
|
|
Source {
|
||
|
|
id: name.clone(),
|
||
|
|
name,
|
||
|
|
description,
|
||
|
|
category: "templates".to_string(),
|
||
|
|
tags: vec!["template".to_string()],
|
||
|
|
downloads: 0,
|
||
|
|
rating: 0.0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== ROUTES =====
|
||
|
|
|
||
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/sources", get(list_sources))
|
||
|
|
.route("/api/sources/:id", get(get_source))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Create Templates
|
||
|
|
|
||
|
|
Create file: `botserver/templates/sources/grid.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="sources-container">
|
||
|
|
<div class="sources-header">
|
||
|
|
<h2>Templates & Sources</h2>
|
||
|
|
<p>Browse and use templates to create new bots</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="sources-grid">
|
||
|
|
{% for source in sources %}
|
||
|
|
<div class="source-card" hx-get="/api/sources/{{ source.id }}" hx-target="#source-detail">
|
||
|
|
<div class="source-icon">📋</div>
|
||
|
|
<h3>{{ source.name }}</h3>
|
||
|
|
<p class="description">{{ source.description }}</p>
|
||
|
|
<div class="source-meta">
|
||
|
|
<span class="category">{{ source.category }}</span>
|
||
|
|
<span class="rating">⭐ {{ source.rating }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endfor %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.sources-container {
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sources-header {
|
||
|
|
margin-bottom: 30px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
padding-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sources-header h2 {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 28px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sources-header p {
|
||
|
|
margin: 5px 0 0 0;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.sources-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
|
|
gap: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.source-card {
|
||
|
|
padding: 15px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 8px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.source-card:hover {
|
||
|
|
background: var(--bg-hover);
|
||
|
|
transform: translateY(-3px);
|
||
|
|
border-color: var(--accent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.source-icon {
|
||
|
|
font-size: 32px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.source-card h3 {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.description {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
flex-grow: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.source-meta {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.category {
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
padding: 2px 6px;
|
||
|
|
border-radius: 3px;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create file: `botserver/templates/sources/detail.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="source-detail">
|
||
|
|
<div class="detail-header">
|
||
|
|
<h1>{{ source.name }}</h1>
|
||
|
|
<div class="actions">
|
||
|
|
<button class="btn-primary" onclick="copyToClipboard()">
|
||
|
|
Copy Template
|
||
|
|
</button>
|
||
|
|
<button class="btn-secondary" onclick="createFromTemplate()">
|
||
|
|
Create Bot from Template
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="detail-body">
|
||
|
|
<div class="description">
|
||
|
|
<h3>Description</h3>
|
||
|
|
<p>{{ source.description }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="template-preview">
|
||
|
|
<h3>Template Code</h3>
|
||
|
|
<pre><code>{{ content }}</code></pre>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.source-detail {
|
||
|
|
padding: 20px;
|
||
|
|
max-width: 1000px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.detail-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
padding-bottom: 15px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
.detail-header h1 {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-primary, .btn-secondary {
|
||
|
|
padding: 8px 15px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 4px;
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-primary {
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-primary:hover {
|
||
|
|
opacity: 0.9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-secondary {
|
||
|
|
background: var(--border-color);
|
||
|
|
color: var(--text-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-secondary:hover {
|
||
|
|
background: var(--bg-hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
.template-preview {
|
||
|
|
margin-top: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.template-preview pre {
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
padding: 15px;
|
||
|
|
border-radius: 6px;
|
||
|
|
overflow-x: auto;
|
||
|
|
max-height: 400px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.template-preview code {
|
||
|
|
font-family: monospace;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-primary);
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Register
|
||
|
|
|
||
|
|
Add to main.rs:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub mod sources;
|
||
|
|
|
||
|
|
// In router:
|
||
|
|
api_router = api_router.merge(botserver::sources::configure());
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 5: Designer Dialog Manager (6-8 hours) ← MOST COMPLEX
|
||
|
|
|
||
|
|
### Step 1: Create Module
|
||
|
|
|
||
|
|
Create file: `botserver/src/designer/mod.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! Designer Module - Bot dialog builder and manager
|
||
|
|
//!
|
||
|
|
//! Provides endpoints for creating, validating, and deploying bot dialogs.
|
||
|
|
|
||
|
|
use axum::{
|
||
|
|
extract::{Path, State},
|
||
|
|
http::StatusCode,
|
||
|
|
response::Html,
|
||
|
|
routing::{delete, get, post, put},
|
||
|
|
Json, Router,
|
||
|
|
};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use uuid::Uuid;
|
||
|
|
use crate::core::shared::state::AppState;
|
||
|
|
use crate::basic::compiler::BASICCompiler;
|
||
|
|
|
||
|
|
// ===== TYPES =====
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct CreateDialogRequest {
|
||
|
|
pub name: String,
|
||
|
|
pub content: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize, Clone)]
|
||
|
|
pub struct DialogResponse {
|
||
|
|
pub id: String,
|
||
|
|
pub bot_id: String,
|
||
|
|
pub name: String,
|
||
|
|
pub content: String,
|
||
|
|
pub status: String, // "draft", "valid", "deployed"
|
||
|
|
pub created_at: String,
|
||
|
|
pub updated_at: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
pub struct ValidationResult {
|
||
|
|
pub valid: bool,
|
||
|
|
pub errors: Vec<String>,
|
||
|
|
pub warnings: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== HANDLERS =====
|
||
|
|
|
||
|
|
/// List dialogs for a bot
|
||
|
|
pub async fn list_dialogs(
|
||
|
|
Path(bot_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = format!("{}.gbai", bot_id);
|
||
|
|
|
||
|
|
// List .bas files from .gbdialogs folder
|
||
|
|
let objects = drive
|
||
|
|
.list_objects(&bucket, ".gbdialogs/")
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let mut dialogs = Vec::new();
|
||
|
|
|
||
|
|
for obj in objects {
|
||
|
|
if obj.key.ends_with(".bas") {
|
||
|
|
let dialog_name = obj
|
||
|
|
.key
|
||
|
|
.split('/')
|
||
|
|
.last()
|
||
|
|
.unwrap_or("unknown")
|
||
|
|
.trim_end_matches(".bas");
|
||
|
|
|
||
|
|
dialogs.push(DialogResponse {
|
||
|
|
id: dialog_name.to_string(),
|
||
|
|
bot_id: bot_id.clone(),
|
||
|
|
name: dialog_name.to_string(),
|
||
|
|
content: String::new(),
|
||
|
|
status: "deployed".to_string(),
|
||
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "designer/dialogs_list.html")]
|
||
|
|
struct ListTemplate {
|
||
|
|
dialogs: Vec<DialogResponse>,
|
||
|
|
bot_id: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = ListTemplate { dialogs, bot_id };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Create new dialog
|
||
|
|
pub async fn create_dialog(
|
||
|
|
Path(bot_id): Path<String>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
Json(req): Json<CreateDialogRequest>,
|
||
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = format!("{}.gbai", bot_id);
|
||
|
|
let key = format!(".gbdialogs/{}.bas", req.name);
|
||
|
|
|
||
|
|
// Store dialog in Drive
|
||
|
|
drive
|
||
|
|
.put_object(&bucket, &key, req.content.clone().into_bytes())
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
Ok(Json(serde_json::json!({
|
||
|
|
"success": true,
|
||
|
|
"id": req.name,
|
||
|
|
"message": "Dialog created successfully"
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get dialog content
|
||
|
|
pub async fn get_dialog(
|
||
|
|
Path((bot_id, dialog_id)): Path<(String, String)>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> Result<Html<String>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = format!("{}.gbai", bot_id);
|
||
|
|
let key = format!(".gbdialogs/{}.bas", dialog_id);
|
||
|
|
|
||
|
|
let content = drive
|
||
|
|
.get_object(&bucket, &key)
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||
|
|
|
||
|
|
let content_str =
|
||
|
|
String::from_utf8(content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let dialog = DialogResponse {
|
||
|
|
id: dialog_id,
|
||
|
|
bot_id,
|
||
|
|
name: String::new(),
|
||
|
|
content: content_str,
|
||
|
|
status: "deployed".to_string(),
|
||
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
};
|
||
|
|
|
||
|
|
use askama::Template;
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "designer/dialog_editor.html")]
|
||
|
|
struct EditorTemplate {
|
||
|
|
dialog: DialogResponse,
|
||
|
|
}
|
||
|
|
|
||
|
|
let template = EditorTemplate { dialog };
|
||
|
|
|
||
|
|
Ok(Html(
|
||
|
|
template
|
||
|
|
.render()
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validate dialog BASIC syntax
|
||
|
|
pub async fn validate_dialog(
|
||
|
|
Path((bot_id, dialog_id)): Path<(String, String)>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
Json(payload): Json<serde_json::Value>,
|
||
|
|
) -> Json<ValidationResult> {
|
||
|
|
let content = payload
|
||
|
|
.get("content")
|
||
|
|
.and_then(|v| v.as_str())
|
||
|
|
.unwrap_or("");
|
||
|
|
|
||
|
|
// Use BASIC compiler to validate
|
||
|
|
let compiler = BASICCompiler::new();
|
||
|
|
match compiler.compile(content) {
|
||
|
|
Ok(_) => Json(ValidationResult {
|
||
|
|
valid: true,
|
||
|
|
errors: vec![],
|
||
|
|
warnings: vec![],
|
||
|
|
}),
|
||
|
|
Err(e) => Json(ValidationResult {
|
||
|
|
valid: false,
|
||
|
|
errors: vec![e.to_string()],
|
||
|
|
warnings: vec![],
|
||
|
|
}),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Update dialog
|
||
|
|
pub async fn update_dialog(
|
||
|
|
Path((bot_id, dialog_id)): Path<(String, String)>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
Json(req): Json<CreateDialogRequest>,
|
||
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||
|
|
let drive = state
|
||
|
|
.drive
|
||
|
|
.as_ref()
|
||
|
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
let bucket = format!("{}.gbai", bot_id);
|
||
|
|
let key = format!(".gbdialogs/{}.bas", dialog_id);
|
||
|
|
|
||
|
|
drive
|
||
|
|
.put_object(&bucket, &key, req.content.clone().into_bytes())
|
||
|
|
.await
|
||
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
|
|
|
||
|
|
Ok(Json(serde_json::json!({
|
||
|
|
"success": true,
|
||
|
|
"message": "Dialog updated successfully"
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Delete dialog
|
||
|
|
pub async fn delete_dialog(
|
||
|
|
Path((bot_id, dialog_id)): Path<(String, String)>,
|
||
|
|
State(state): State<Arc<AppState>>,
|
||
|
|
) -> StatusCode {
|
||
|
|
let drive = match state.drive.as_ref() {
|
||
|
|
Some(d) => d,
|
||
|
|
None => return StatusCode::INTERNAL_SERVER_ERROR,
|
||
|
|
};
|
||
|
|
|
||
|
|
let bucket = format!("{}.gbai", bot_id);
|
||
|
|
let key = format!(".gbdialogs/{}.bas", dialog_id);
|
||
|
|
|
||
|
|
match drive.delete_object(&bucket, &key).await {
|
||
|
|
Ok(_) => StatusCode::NO_CONTENT,
|
||
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== ROUTES =====
|
||
|
|
|
||
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/bots/:bot_id/dialogs", get(list_dialogs).post(create_dialog))
|
||
|
|
.route(
|
||
|
|
"/api/bots/:bot_id/dialogs/:dialog_id",
|
||
|
|
get(get_dialog).put(update_dialog).delete(delete_dialog),
|
||
|
|
)
|
||
|
|
.route(
|
||
|
|
"/api/bots/:bot_id/dialogs/:dialog_id/validate",
|
||
|
|
post(validate_dialog),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Create Templates
|
||
|
|
|
||
|
|
Create file: `botserver/templates/designer/dialogs_list.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="dialogs-manager">
|
||
|
|
<div class="dialogs-header">
|
||
|
|
<h2>Dialogs for {{ bot_id }}</h2>
|
||
|
|
<button hx-post="/api/bots/{{ bot_id }}/dialogs"
|
||
|
|
hx-prompt="Enter dialog name:"
|
||
|
|
hx-target="#dialogs-list">
|
||
|
|
+ New Dialog
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table class="dialogs-table" id="dialogs-list">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th>Created</th>
|
||
|
|
<th>Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for dialog in dialogs %}
|
||
|
|
<tr>
|
||
|
|
<td><strong>{{ dialog.name }}</strong></td>
|
||
|
|
<td><span class="status">{{ dialog.status }}</span></td>
|
||
|
|
<td>{{ dialog.created_at | truncate(10) }}</td>
|
||
|
|
<td class="actions">
|
||
|
|
<button hx-get="/api/bots/{{ bot_id }}/dialogs/{{ dialog.id }}"
|
||
|
|
hx-target="#dialog-editor">
|
||
|
|
Edit
|
||
|
|
</button>
|
||
|
|
<button hx-delete="/api/bots/{{ bot_id }}/dialogs/{{ dialog.id }}"
|
||
|
|
hx-confirm="Delete this dialog?"
|
||
|
|
hx-target="closest tr"
|
||
|
|
hx-swap="swap:1s">
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.dialogs-manager {
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dialogs-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dialogs-table {
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
}
|
||
|
|
|
||
|
|
th {
|
||
|
|
text-align: left;
|
||
|
|
padding: 12px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
td {
|
||
|
|
padding: 12px;
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
.status {
|
||
|
|
display: inline-block;
|
||
|
|
padding: 2px 8px;
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
border-radius: 3px;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.actions button {
|
||
|
|
margin-right: 5px;
|
||
|
|
padding: 5px 10px;
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 3px;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.actions button:hover {
|
||
|
|
opacity: 0.8;
|
||
|
|
}
|
||
|
|
|
||
|
|
button {
|
||
|
|
padding: 8px 15px;
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 4px;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
button:hover {
|
||
|
|
opacity: 0.9;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create file: `botserver/templates/designer/dialog_editor.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="dialog-editor">
|
||
|
|
<div class="editor-header">
|
||
|
|
<h2>{{ dialog.name }}</h2>
|
||
|
|
<div class="editor-actions">
|
||
|
|
<button onclick="validateDialog()" class="btn-validate">Validate</button>
|
||
|
|
<button onclick="saveDialog()" class="btn-save">Save</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<textarea id="dialog-content" class="editor-textarea">{{ dialog.content }}</textarea>
|
||
|
|
|
||
|
|
<div id="validation-results" class="validation-hidden"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.dialog-editor {
|
||
|
|
padding: 20px;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
height: 600px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.editor-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.editor-textarea {
|
||
|
|
flex: 1;
|
||
|
|
padding: 15px;
|
||
|
|
font-family: monospace;
|
||
|
|
font-size: 13px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 4px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
color: var(--text-primary);
|
||
|
|
resize: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-validate, .btn-save {
|
||
|
|
padding: 8px 15px;
|
||
|
|
background: var(--accent);
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 4px;
|
||
|
|
cursor: pointer;
|
||
|
|
margin-left: 5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-hidden {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-results {
|
||
|
|
margin-top: 15px;
|
||
|
|
padding: 15px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-error {
|
||
|
|
color: #dc3545;
|
||
|
|
background: #f8d7da;
|
||
|
|
border: 1px solid #f5c6cb;
|
||
|
|
padding: 10px;
|
||
|
|
border-radius: 3px;
|
||
|
|
margin-bottom: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-success {
|
||
|
|
color: #155724;
|
||
|
|
background: #d4edda;
|
||
|
|
border: 1px solid #c3e6cb;
|
||
|
|
padding: 10px;
|
||
|
|
border-radius: 3px;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
function validateDialog() {
|
||
|
|
const content = document.getElementById('dialog-content').value;
|
||
|
|
const resultsDiv = document.getElementById('validation-results');
|
||
|
|
|
||
|
|
fetch('validate', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ content })
|
||
|
|
})
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(data => {
|
||
|
|
resultsDiv.classList.remove('validation-hidden');
|
||
|
|
resultsDiv.classList.add('validation-results');
|
||
|
|
|
||
|
|
if (data.valid) {
|
||
|
|
resultsDiv.innerHTML = '<div class="validation-success">✓ Syntax is valid!</div>';
|
||
|
|
} else {
|
||
|
|
resultsDiv.innerHTML = '<div class="validation-error">✗ Errors found:<br>' +
|
||
|
|
data.errors.join('<br>') + '</div>';
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveDialog() {
|
||
|
|
const content = document.getElementById('dialog-content').value;
|
||
|
|
// Save via HTMX/backend
|
||
|
|
alert('Save functionality to be implemented');
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Register
|
||
|
|
|
||
|
|
Add to main.rs:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub mod designer;
|
||
|
|
|
||
|
|
// In router:
|
||
|
|
api_router = api_router.merge(botserver::designer::configure());
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Final Steps: Testing & Deployment
|
||
|
|
|
||
|
|
### Test All Endpoints
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Analytics
|
||
|
|
curl -X GET "http://localhost:3000/api/analytics/dashboard?time_range=day"
|
||
|
|
|
||
|
|
# Paper
|
||
|
|
curl -X POST "http://localhost:3000/api/documents" \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"title":"Test","content":"Test","doc_type":"draft"}'
|
||
|
|
|
||
|
|
# Research (update existing endpoint)
|
||
|
|
curl -X GET "http://localhost:3000/api/kb/search?q=test"
|
||
|
|
|
||
|
|
# Sources
|
||
|
|
curl -X GET "http://localhost:3000/api/sources"
|
||
|
|
|
||
|
|
# Designer
|
||
|
|
curl -X GET "http://localhost:3000/api/bots/my-bot/dialogs"
|
||
|
|
```
|
||
|
|
|
||
|
|
### Build & Deploy
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cargo build --release
|
||
|
|
# Deploy binary to production
|
||
|
|
# All 5 apps now fully functional!
|
||
|
|
```
|
||
|
|
|
||
|
|
### Verify in UI
|
||
|
|
|
||
|
|
1. Open http://localhost:3000
|
||
|
|
2. Click each app in sidebar
|
||
|
|
3. Verify functionality works
|
||
|
|
4. Check browser Network tab for requests
|
||
|
|
5. Ensure no errors in console
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Success Checklist
|
||
|
|
|
||
|
|
- ✅ All 5 modules created
|
||
|
|
- ✅ All handlers implemented
|
||
|
|
- ✅ All templates created and render correctly
|
||
|
|
- ✅ All routes registered in main.rs
|
||
|
|
- ✅ All endpoints tested manually
|
||
|
|
- ✅ Frontend HTMX attributes work
|
||
|
|
- ✅ No 404 errors
|
||
|
|
- ✅ No database errors
|
||
|
|
- ✅ Response times acceptable
|
||
|
|
- ✅ Ready for production
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## You're Done! 🎉
|
||
|
|
|
||
|
|
By following this guide, you will have:
|
||
|
|
- ✅ Implemented all 5 missing apps
|
||
|
|
- ✅ Created ~50+ Askama templates
|
||
|
|
- ✅ Added ~20 handler functions
|
||
|
|
- ✅ Wired up HTMX integration
|
||
|
|
- ✅ Achieved 100% feature parity with documentation
|
||
|
|
- ✅ Completed ~20-25 hours of work
|
||
|
|
|
||
|
|
The General Bots Suite is now fully functional with all 11+ apps working!
|