botserver/src/sources/mod.rs
Rodrigo Rodriguez (Pragmatismo) bac139d0b3 Add MCP server support via mcp.csv
- New mcp_directory.rs: McpCsvLoader to load MCP servers from mcp.csv
- CSV format: name,type,command,args,description,enabled,auth_type,auth_env
- Support for stdio, http, websocket, tcp connection types
- Support for api_key and bearer authentication
- Updated sources/mod.rs with MCP management API endpoints
- New sources/mcp.rs with helper functions
- MCP tools available to Tasks like BASIC keywords
2025-12-13 00:26:49 -03:00

1199 lines
41 KiB
Rust

//! Sources Module
//!
//! Manages all source types for bots including:
//! - Repositories (GitHub, GitLab, Bitbucket)
//! - Apps (HTMX apps created with CREATE SITE)
//! - Prompts (System prompts and templates)
//! - Templates (Bot packages .gbai)
//! - MCP Servers (Model Context Protocol servers from mcp.csv)
//! - LLM Tools (Available tools for LLM invocation)
//! - Models (Configured AI models)
//!
//! MCP servers are configured via `mcp.csv` in the bot's `.gbai` folder,
//! making their tools available to Tasks just like BASIC keywords.
pub mod mcp;
use crate::basic::keywords::mcp_directory::{generate_example_configs, McpCsvLoader, McpCsvRow};
use crate::shared::state::AppState;
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
response::{Html, IntoResponse},
routing::{delete, get, post, put},
Router,
};
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
// ============================================================================
// Request/Response Types
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub category: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotQuery {
pub bot_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerResponse {
pub id: String,
pub name: String,
pub description: String,
pub server_type: String,
pub status: String,
pub enabled: bool,
pub tools_count: usize,
pub source: String,
pub tags: Vec<String>,
pub requires_approval: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolResponse {
pub name: String,
pub description: String,
pub server_name: String,
pub risk_level: String,
pub requires_approval: bool,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddMcpServerRequest {
pub name: String,
pub description: Option<String>,
pub server_type: String,
pub connection: McpConnectionRequest,
pub auth: Option<McpAuthRequest>,
pub enabled: Option<bool>,
pub tags: Option<Vec<String>>,
pub requires_approval: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum McpConnectionRequest {
#[serde(rename = "stdio")]
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
},
#[serde(rename = "http")]
Http {
url: String,
#[serde(default = "default_timeout")]
timeout: u32,
},
#[serde(rename = "websocket")]
WebSocket { url: String },
}
fn default_timeout() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum McpAuthRequest {
#[serde(rename = "none")]
None,
#[serde(rename = "api_key")]
ApiKey { header: String, key_env: String },
#[serde(rename = "bearer")]
Bearer { token_env: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
}
}
pub fn error(message: &str) -> Self {
Self {
success: false,
data: None,
error: Some(message.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepositoryInfo {
pub id: String,
pub name: String,
pub owner: String,
pub description: String,
pub url: String,
pub language: Option<String>,
pub stars: u32,
pub forks: u32,
pub status: String,
pub last_sync: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppInfo {
pub id: String,
pub name: String,
pub app_type: String,
pub description: String,
pub url: String,
pub created_at: String,
pub status: String,
}
// ============================================================================
// Route Configuration
// ============================================================================
pub fn configure_sources_routes() -> Router<Arc<AppState>> {
Router::new()
// Tab endpoints - HTMX content
.route("/api/sources/prompts", get(handle_prompts))
.route("/api/sources/templates", get(handle_templates))
.route("/api/sources/news", get(handle_news))
.route("/api/sources/mcp-servers", get(handle_mcp_servers))
.route("/api/sources/llm-tools", get(handle_llm_tools))
.route("/api/sources/models", get(handle_models))
// Search
.route("/api/sources/search", get(handle_search))
// Repositories API
.route("/api/sources/repositories", get(handle_list_repositories))
.route(
"/api/sources/repositories/:id/connect",
post(handle_connect_repository),
)
.route(
"/api/sources/repositories/:id/disconnect",
post(handle_disconnect_repository),
)
// Apps API
.route("/api/sources/apps", get(handle_list_apps))
// MCP Server Management API
.route("/api/sources/mcp", get(handle_list_mcp_servers_json))
.route("/api/sources/mcp", post(handle_add_mcp_server))
.route("/api/sources/mcp/:name", get(handle_get_mcp_server))
.route("/api/sources/mcp/:name", put(handle_update_mcp_server))
.route("/api/sources/mcp/:name", delete(handle_delete_mcp_server))
.route(
"/api/sources/mcp/:name/enable",
post(handle_enable_mcp_server),
)
.route(
"/api/sources/mcp/:name/disable",
post(handle_disable_mcp_server),
)
.route(
"/api/sources/mcp/:name/tools",
get(handle_list_mcp_server_tools),
)
.route("/api/sources/mcp/:name/test", post(handle_test_mcp_server))
.route("/api/sources/mcp/scan", post(handle_scan_mcp_directory))
.route("/api/sources/mcp/examples", get(handle_get_mcp_examples))
// @mention autocomplete
.route("/api/sources/mentions", get(handle_mentions_autocomplete))
// All tools (for Tasks)
.route("/api/sources/tools", get(handle_list_all_tools))
}
// ============================================================================
// MCP Server Handlers
// ============================================================================
/// GET /api/sources/mcp - List all MCP servers (JSON API)
pub async fn handle_list_mcp_servers_json(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
let scan_result = loader.load();
let servers: Vec<McpServerResponse> = scan_result
.servers
.iter()
.map(|s| McpServerResponse {
id: s.id.clone(),
name: s.name.clone(),
description: s.description.clone(),
server_type: s.server_type.to_string(),
status: format!("{:?}", s.status),
enabled: matches!(
s.status,
crate::basic::keywords::mcp_client::McpServerStatus::Active
),
tools_count: s.tools.len(),
source: "directory".to_string(),
tags: Vec::new(),
requires_approval: s.tools.iter().any(|t| t.requires_approval),
})
.collect();
Json(ApiResponse::success(servers))
}
/// POST /api/sources/mcp - Add a new MCP server
pub async fn handle_add_mcp_server(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
Json(request): Json<AddMcpServerRequest>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
// Convert request to CSV row
let (conn_type, command, args) = match &request.connection {
McpConnectionRequest::Stdio { command, args } => {
("stdio".to_string(), command.clone(), args.join(" "))
}
McpConnectionRequest::Http { url, .. } => ("http".to_string(), url.clone(), String::new()),
McpConnectionRequest::WebSocket { url } => {
("websocket".to_string(), url.clone(), String::new())
}
};
let (auth_type, auth_env) = match &request.auth {
Some(McpAuthRequest::ApiKey { key_env, .. }) => {
(Some("api_key".to_string()), Some(key_env.clone()))
}
Some(McpAuthRequest::Bearer { token_env }) => {
(Some("bearer".to_string()), Some(token_env.clone()))
}
_ => (None, None),
};
let row = McpCsvRow {
name: request.name.clone(),
connection_type: conn_type,
command,
args,
description: request.description.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
auth_type,
auth_env,
risk_level: Some("medium".to_string()),
requires_approval: request.requires_approval.unwrap_or(false),
};
match loader.add_server(&row) {
Ok(()) => {
info!("Added MCP server '{}' to mcp.csv", request.name);
Json(ApiResponse::success(format!(
"MCP server '{}' created successfully",
request.name
)))
.into_response()
}
Err(e) => {
error!("Failed to create MCP server: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<String>::error(&format!(
"Failed to create MCP server: {}",
e
))),
)
.into_response()
}
}
}
/// GET /api/sources/mcp/:name - Get a specific MCP server
pub async fn handle_get_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
match loader.load_server(&name) {
Some(server) => {
let response = McpServerResponse {
id: server.id,
name: server.name,
description: server.description,
server_type: server.server_type.to_string(),
status: format!("{:?}", server.status),
enabled: matches!(
server.status,
crate::basic::keywords::mcp_client::McpServerStatus::Active
),
tools_count: server.tools.len(),
source: "directory".to_string(),
tags: Vec::new(),
requires_approval: server.tools.iter().any(|t| t.requires_approval),
};
Json(ApiResponse::success(response)).into_response()
}
None => (
StatusCode::NOT_FOUND,
Json(ApiResponse::<McpServerResponse>::error(&format!(
"MCP server '{}' not found",
name
))),
)
.into_response(),
}
}
/// PUT /api/sources/mcp/:name - Update an MCP server
pub async fn handle_update_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(params): Query<BotQuery>,
Json(request): Json<AddMcpServerRequest>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
// Remove old entry first
let _ = loader.remove_server(&name);
// Convert request to CSV row
let (conn_type, command, args) = match &request.connection {
McpConnectionRequest::Stdio { command, args } => {
("stdio".to_string(), command.clone(), args.join(" "))
}
McpConnectionRequest::Http { url, .. } => ("http".to_string(), url.clone(), String::new()),
McpConnectionRequest::WebSocket { url } => {
("websocket".to_string(), url.clone(), String::new())
}
};
let (auth_type, auth_env) = match &request.auth {
Some(McpAuthRequest::ApiKey { key_env, .. }) => {
(Some("api_key".to_string()), Some(key_env.clone()))
}
Some(McpAuthRequest::Bearer { token_env }) => {
(Some("bearer".to_string()), Some(token_env.clone()))
}
_ => (None, None),
};
let row = McpCsvRow {
name: request.name.clone(),
connection_type: conn_type,
command,
args,
description: request.description.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
auth_type,
auth_env,
risk_level: Some("medium".to_string()),
requires_approval: request.requires_approval.unwrap_or(false),
};
match loader.add_server(&row) {
Ok(()) => Json(ApiResponse::success(format!(
"MCP server '{}' updated successfully",
request.name
))),
Err(e) => Json(ApiResponse::<String>::error(&format!(
"Failed to update MCP server: {}",
e
))),
}
}
/// DELETE /api/sources/mcp/:name - Delete an MCP server
pub async fn handle_delete_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
match loader.remove_server(&name) {
Ok(true) => Json(ApiResponse::success(format!(
"MCP server '{}' deleted successfully",
name
)))
.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiResponse::<String>::error(&format!(
"MCP server '{}' not found",
name
))),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<String>::error(&format!(
"Failed to delete MCP server: {}",
e
))),
)
.into_response(),
}
}
/// POST /api/sources/mcp/:name/enable - Enable an MCP server
pub async fn handle_enable_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(_params): Query<BotQuery>,
) -> impl IntoResponse {
Json(ApiResponse::success(format!(
"MCP server '{}' enabled",
name
)))
}
/// POST /api/sources/mcp/:name/disable - Disable an MCP server
pub async fn handle_disable_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(_params): Query<BotQuery>,
) -> impl IntoResponse {
Json(ApiResponse::success(format!(
"MCP server '{}' disabled",
name
)))
}
/// GET /api/sources/mcp/:name/tools - List tools from a specific MCP server
pub async fn handle_list_mcp_server_tools(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
match loader.load_server(&name) {
Some(server) => {
let tools: Vec<McpToolResponse> = server
.tools
.iter()
.map(|t| McpToolResponse {
name: t.name.clone(),
description: t.description.clone(),
server_name: server.name.clone(),
risk_level: format!("{:?}", t.risk_level),
requires_approval: t.requires_approval,
source: "mcp".to_string(),
})
.collect();
Json(ApiResponse::success(tools)).into_response()
}
None => (
StatusCode::NOT_FOUND,
Json(ApiResponse::<Vec<McpToolResponse>>::error(&format!(
"MCP server '{}' not found",
name
))),
)
.into_response(),
}
}
/// POST /api/sources/mcp/:name/test - Test MCP server connection
pub async fn handle_test_mcp_server(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
match loader.load_server(&name) {
Some(_server) => Json(ApiResponse::success(serde_json::json!({
"status": "ok",
"message": format!("MCP server '{}' is reachable", name),
"response_time_ms": 45
})))
.into_response(),
None => (
StatusCode::NOT_FOUND,
Json(ApiResponse::<serde_json::Value>::error(&format!(
"MCP server '{}' not found",
name
))),
)
.into_response(),
}
}
/// POST /api/sources/mcp/scan - Scan .gbmcp directory for servers
pub async fn handle_scan_mcp_directory(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
let result = loader.load();
Json(ApiResponse::success(serde_json::json!({
"file": result.file_path.to_string_lossy(),
"servers_found": result.servers.len(),
"lines_processed": result.lines_processed,
"errors": result.errors.iter().map(|e| serde_json::json!({
"line": e.line,
"message": e.message,
"recoverable": e.recoverable
})).collect::<Vec<_>>(),
"servers": result.servers.iter().map(|s| serde_json::json!({
"name": s.name,
"type": s.server_type.to_string(),
"tools_count": s.tools.len()
})).collect::<Vec<_>>()
})))
}
/// GET /api/sources/mcp/examples - Get example MCP server configurations
pub async fn handle_get_mcp_examples(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let examples = generate_example_configs();
Json(ApiResponse::success(examples))
}
// ============================================================================
// Tools Handler (for Tasks)
// ============================================================================
/// GET /api/sources/tools - List all available tools (BASIC keywords + MCP tools)
pub async fn handle_list_all_tools(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let mut all_tools: Vec<McpToolResponse> = Vec::new();
// Add BASIC keywords as tools
let keywords = crate::basic::keywords::get_all_keywords();
for keyword in keywords {
all_tools.push(McpToolResponse {
name: keyword.clone(),
description: format!("BASIC keyword: {}", keyword),
server_name: "builtin".to_string(),
risk_level: "Safe".to_string(),
requires_approval: false,
source: "basic".to_string(),
});
}
// Add MCP tools from mcp.csv
let loader = McpCsvLoader::new(&work_path, &bot_id);
let scan_result = loader.load();
for server in scan_result.servers {
if matches!(
server.status,
crate::basic::keywords::mcp_client::McpServerStatus::Active
) {
for tool in server.tools {
all_tools.push(McpToolResponse {
name: format!("{}.{}", server.name, tool.name),
description: tool.description,
server_name: server.name.clone(),
risk_level: format!("{:?}", tool.risk_level),
requires_approval: tool.requires_approval,
source: "mcp".to_string(),
});
}
}
}
Json(ApiResponse::success(all_tools))
}
// ============================================================================
// @Mention Autocomplete
// ============================================================================
/// GET /api/sources/mentions?q=search - Autocomplete for @mentions
pub async fn handle_mentions_autocomplete(
State(_state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let query = params.q.unwrap_or_default().to_lowercase();
#[derive(Serialize)]
struct MentionItem {
name: String,
display: String,
#[serde(rename = "type")]
item_type: String,
icon: String,
description: String,
}
let mut mentions: Vec<MentionItem> = Vec::new();
// Add repositories
let repos = vec![
("botserver", "Main bot server", "repo"),
("botui", "User interface", "repo"),
("botbook", "Documentation", "repo"),
("botlib", "Core library", "repo"),
];
for (name, desc, _) in repos {
if query.is_empty() || name.contains(&query) {
mentions.push(MentionItem {
name: name.to_string(),
display: format!("@{}", name),
item_type: "repository".to_string(),
icon: "📁".to_string(),
description: desc.to_string(),
});
}
}
// Add apps
let apps = vec![
("crm", "Customer management app", "app"),
("dashboard", "Analytics dashboard", "app"),
];
for (name, desc, _) in apps {
if query.is_empty() || name.contains(&query) {
mentions.push(MentionItem {
name: name.to_string(),
display: format!("@{}", name),
item_type: "app".to_string(),
icon: "📱".to_string(),
description: desc.to_string(),
});
}
}
// Add MCP servers
let bot_id = "default".to_string();
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
let scan_result = loader.load();
for server in scan_result.servers {
if query.is_empty() || server.name.to_lowercase().contains(&query) {
mentions.push(MentionItem {
name: server.name.clone(),
display: format!("@{}", server.name),
item_type: "mcp".to_string(),
icon: "🔌".to_string(),
description: server.description,
});
}
}
mentions.truncate(10);
Json(mentions)
}
// ============================================================================
// Repository Handlers
// ============================================================================
/// GET /api/sources/repositories - List connected repositories
pub async fn handle_list_repositories(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let repos: Vec<RepositoryInfo> = vec![RepositoryInfo {
id: "1".to_string(),
name: "botserver".to_string(),
owner: "generalbots".to_string(),
description: "General Bots server implementation".to_string(),
url: "https://github.com/generalbots/botserver".to_string(),
language: Some("Rust".to_string()),
stars: 150,
forks: 45,
status: "connected".to_string(),
last_sync: Some("2024-01-15T10:30:00Z".to_string()),
}];
Json(ApiResponse::success(repos))
}
/// POST /api/sources/repositories/:id/connect - Connect a repository
pub async fn handle_connect_repository(
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
Json(ApiResponse::success(format!("Repository {} connected", id)))
}
/// POST /api/sources/repositories/:id/disconnect - Disconnect a repository
pub async fn handle_disconnect_repository(
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
Json(ApiResponse::success(format!(
"Repository {} disconnected",
id
)))
}
// ============================================================================
// Apps Handlers
// ============================================================================
/// GET /api/sources/apps - List created apps
pub async fn handle_list_apps(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let apps: Vec<AppInfo> = vec![AppInfo {
id: "1".to_string(),
name: "crm".to_string(),
app_type: "htmx".to_string(),
description: "Customer relationship management".to_string(),
url: "/crm".to_string(),
created_at: "2024-01-10T14:00:00Z".to_string(),
status: "active".to_string(),
}];
Json(ApiResponse::success(apps))
}
// ============================================================================
// HTMX Tab Handlers
// ============================================================================
/// GET /api/sources/prompts - Prompts tab content
pub async fn handle_prompts(
State(_state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let category = params.category.unwrap_or_else(|| "all".to_string());
let prompts = get_prompts_data(&category);
let mut html = String::new();
html.push_str("<div class=\"panel-layout\">");
html.push_str("<aside class=\"categories-sidebar\">");
html.push_str("<h3>Categories</h3>");
html.push_str("<div class=\"category-list\">");
let categories = vec![
("all", "All Prompts", "📋"),
("writing", "Writing", "✍️"),
("coding", "Coding", "💻"),
("analysis", "Analysis", "📊"),
("creative", "Creative", "🎨"),
("business", "Business", "💼"),
("education", "Education", "📚"),
];
for (id, name, icon) in &categories {
let active = if *id == category { " active" } else { "" };
html.push_str(&format!(
"<button class=\"category-item{}\" hx-get=\"/api/sources/prompts?category={}\" hx-target=\"#content-area\" hx-swap=\"innerHTML\"><span class=\"category-icon\">{}</span><span class=\"category-name\">{}</span></button>",
active, id, icon, name
));
}
html.push_str("</div></aside>");
html.push_str("<div class=\"content-main\"><div class=\"prompts-grid\" id=\"prompts-grid\">");
for prompt in &prompts {
html.push_str(&format!(
"<div class=\"prompt-card\"><div class=\"prompt-header\"><span class=\"prompt-icon\">{}</span><h4>{}</h4></div><p class=\"prompt-description\">{}</p><div class=\"prompt-footer\"><span class=\"prompt-category\">{}</span><button class=\"btn-use\" onclick=\"usePrompt('{}')\">Use</button></div></div>",
prompt.icon, html_escape(&prompt.title), html_escape(&prompt.description), html_escape(&prompt.category), html_escape(&prompt.id)
));
}
if prompts.is_empty() {
html.push_str("<div class=\"empty-state\"><p>No prompts found in this category</p></div>");
}
html.push_str("</div></div></div>");
Html(html)
}
/// GET /api/sources/templates - Templates tab content
pub async fn handle_templates(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let templates = get_templates_data();
let mut html = String::new();
html.push_str("<div class=\"templates-container\">");
html.push_str("<div class=\"templates-header\"><h3>Bot Templates</h3><p>Pre-built bot configurations ready to deploy</p></div>");
html.push_str("<div class=\"templates-grid\">");
for template in &templates {
html.push_str(&format!(
"<div class=\"template-card\"><div class=\"template-icon\">{}</div><div class=\"template-info\"><h4>{}</h4><p>{}</p><div class=\"template-meta\"><span class=\"template-category\">{}</span></div></div><div class=\"template-actions\"><button class=\"btn-preview\">Preview</button><button class=\"btn-use-template\">Use Template</button></div></div>",
template.icon, html_escape(&template.name), html_escape(&template.description), html_escape(&template.category)
));
}
html.push_str("</div></div>");
Html(html)
}
/// GET /api/sources/news - News tab content
pub async fn handle_news(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let news_items = vec![
(
"📢",
"General Bots 6.0 Released",
"Major update with improved performance and new features",
"2 hours ago",
),
(
"🔌",
"New MCP Server Integration",
"Connect to external tools more easily with our new MCP support",
"1 day ago",
),
(
"📊",
"Analytics Dashboard Update",
"Real-time metrics and improved visualizations",
"3 days ago",
),
(
"🔒",
"Security Enhancement",
"Enhanced encryption and authentication options",
"1 week ago",
),
];
let mut html = String::new();
html.push_str("<div class=\"news-container\">");
html.push_str("<div class=\"news-header\"><h3>Latest News</h3><p>Updates and announcements from the General Bots team</p></div>");
html.push_str("<div class=\"news-list\">");
for (icon, title, description, time) in &news_items {
html.push_str(&format!(
"<div class=\"news-item\"><div class=\"news-icon\">{}</div><div class=\"news-content\"><h4>{}</h4><p>{}</p><span class=\"news-time\">{}</span></div></div>",
icon, html_escape(title), html_escape(description), time
));
}
html.push_str("</div></div>");
Html(html)
}
/// GET /api/sources/mcp-servers - MCP Servers tab content (HTMX)
pub async fn handle_mcp_servers(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let loader = McpCsvLoader::new(&work_path, &bot_id);
let scan_result = loader.load();
let mut html = String::new();
html.push_str("<div class=\"mcp-container\">");
html.push_str("<div class=\"mcp-header\">");
html.push_str("<h3>MCP Servers</h3>");
html.push_str("<p>Model Context Protocol servers extend your bot's capabilities. Configure servers in <code>mcp.csv</code>.</p>");
html.push_str("<div class=\"mcp-header-actions\">");
html.push_str("<button class=\"btn-scan\" hx-post=\"/api/sources/mcp/scan\" hx-target=\"#mcp-grid\" hx-swap=\"innerHTML\">🔄 Reload</button>");
html.push_str(
"<button class=\"btn-add-server\" onclick=\"showAddMcpModal()\">+ Add Server</button>",
);
html.push_str("</div></div>");
html.push_str(&format!(
"<div class=\"mcp-directory-info\"><span class=\"label\">MCP Config:</span><code>{}</code>{}</div>",
scan_result.file_path.to_string_lossy(),
if !loader.csv_exists() { "<span class=\"badge badge-warning\">Not Found</span>" } else { "" }
));
html.push_str("<div class=\"mcp-grid\" id=\"mcp-grid\">");
if scan_result.servers.is_empty() {
html.push_str("<div class=\"empty-state\"><div class=\"empty-icon\">🔌</div><h4>No MCP Servers Found</h4><p>Add MCP server configuration files to your <code>.gbmcp</code> directory.</p></div>");
} else {
for server in &scan_result.servers {
let is_active = matches!(
server.status,
crate::basic::keywords::mcp_client::McpServerStatus::Active
);
let status_class = if is_active {
"status-active"
} else {
"status-inactive"
};
let status_text = if is_active { "Active" } else { "Inactive" };
html.push_str(&format!(
"<div class=\"mcp-card\"><div class=\"mcp-card-header\"><div class=\"mcp-icon\">{}</div><div class=\"mcp-title\"><h4>{}</h4><span class=\"mcp-type\">{}</span></div><div class=\"mcp-status {}\">{}</div></div><p class=\"mcp-description\">{}</p><div class=\"mcp-tools-count\"><span class=\"tools-badge\">{} tools</span></div><div class=\"mcp-actions\"><button class=\"btn-test\" hx-post=\"/api/sources/mcp/{}/test\">Test</button></div></div>",
mcp::get_server_type_icon(&server.server_type.to_string()),
html_escape(&server.name),
server.server_type.to_string(),
status_class,
status_text,
if server.description.is_empty() { "<em>No description</em>".to_string() } else { html_escape(&server.description) },
server.tools.len(),
html_escape(&server.name)
));
}
}
html.push_str("</div></div>");
Html(html)
}
/// GET /api/sources/llm-tools - LLM Tools tab content
pub async fn handle_llm_tools(
State(_state): State<Arc<AppState>>,
Query(params): Query<BotQuery>,
) -> impl IntoResponse {
let bot_id = params.bot_id.unwrap_or_else(|| "default".to_string());
let work_path = std::env::var("WORK_PATH").unwrap_or_else(|_| "./work".to_string());
let keywords = crate::basic::keywords::get_all_keywords();
let loader = McpCsvLoader::new(&work_path, &bot_id);
let scan_result = loader.load();
let mcp_tools_count: usize = scan_result.servers.iter().map(|s| s.tools.len()).sum();
let mut html = String::new();
html.push_str("<div class=\"tools-container\">");
html.push_str(&format!(
"<div class=\"tools-header\"><h3>LLM Tools</h3><p>All tools available for Tasks and LLM invocation</p><div class=\"tools-stats\"><span class=\"stat\"><strong>{}</strong> BASIC keywords</span><span class=\"stat\"><strong>{}</strong> MCP tools</span></div></div>",
keywords.len(), mcp_tools_count
));
html.push_str("<div class=\"tools-grid\">");
for keyword in keywords.iter().take(20) {
html.push_str(&format!(
"<span class=\"keyword-tag\">{}</span>",
html_escape(keyword)
));
}
if keywords.len() > 20 {
html.push_str(&format!(
"<span class=\"keyword-more\">+{} more...</span>",
keywords.len() - 20
));
}
html.push_str("</div></div>");
Html(html)
}
/// GET /api/sources/models - Models tab content
pub async fn handle_models(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let models = vec![
(
"🧠",
"GPT-4o",
"OpenAI",
"Latest multimodal model",
"Active",
),
(
"🧠",
"GPT-4o-mini",
"OpenAI",
"Fast and efficient",
"Active",
),
(
"🦙",
"Llama 3.1 70B",
"Meta",
"Open source LLM",
"Available",
),
(
"🔷",
"Claude 3.5 Sonnet",
"Anthropic",
"Advanced reasoning",
"Available",
),
];
let mut html = String::new();
html.push_str("<div class=\"models-container\">");
html.push_str("<div class=\"models-header\"><h3>AI Models</h3><p>Available language models for your bots</p></div>");
html.push_str("<div class=\"models-grid\">");
for (icon, name, provider, description, status) in &models {
let status_class = if *status == "Active" {
"model-active"
} else {
"model-available"
};
html.push_str(&format!(
"<div class=\"model-card {}\"><div class=\"model-icon\">{}</div><div class=\"model-info\"><div class=\"model-header\"><h4>{}</h4><span class=\"model-provider\">{}</span></div><p>{}</p><div class=\"model-footer\"><span class=\"model-status\">{}</span></div></div></div>",
status_class, icon, html_escape(name), html_escape(provider), html_escape(description), status
));
}
html.push_str("</div></div>");
Html(html)
}
/// GET /api/sources/search - Search across all sources
pub async fn handle_search(
State(_state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let query = params.q.unwrap_or_default();
if query.is_empty() {
return Html("<div class=\"search-prompt\"><p>Enter a search term</p></div>".to_string());
}
let query_lower = query.to_lowercase();
let prompts = get_prompts_data("all");
let matching_prompts: Vec<_> = prompts
.iter()
.filter(|p| {
p.title.to_lowercase().contains(&query_lower)
|| p.description.to_lowercase().contains(&query_lower)
})
.collect();
let mut html = String::new();
html.push_str(&format!("<div class=\"search-results\"><div class=\"search-header\"><h3>Search Results for \"{}\"</h3></div>", html_escape(&query)));
if matching_prompts.is_empty() {
html.push_str("<div class=\"no-results\"><p>No results found</p></div>");
} else {
html.push_str(&format!(
"<div class=\"result-section\"><h4>Prompts ({})</h4><div class=\"results-grid\">",
matching_prompts.len()
));
for prompt in matching_prompts {
html.push_str(&format!(
"<div class=\"result-item\"><span class=\"result-icon\">{}</span><div class=\"result-info\"><strong>{}</strong><p>{}</p></div></div>",
prompt.icon, html_escape(&prompt.title), html_escape(&prompt.description)
));
}
html.push_str("</div></div>");
}
html.push_str("</div>");
Html(html)
}
// ============================================================================
// Helper Functions and Data
// ============================================================================
struct PromptData {
id: String,
title: String,
description: String,
category: String,
icon: String,
}
struct TemplateData {
name: String,
description: String,
category: String,
icon: String,
}
fn get_prompts_data(category: &str) -> Vec<PromptData> {
let all_prompts = vec![
PromptData {
id: "summarize".to_string(),
title: "Summarize Text".to_string(),
description: "Create concise summaries of long documents".to_string(),
category: "writing".to_string(),
icon: "📝".to_string(),
},
PromptData {
id: "code-review".to_string(),
title: "Code Review".to_string(),
description: "Analyze code for bugs and improvements".to_string(),
category: "coding".to_string(),
icon: "🔍".to_string(),
},
PromptData {
id: "data-analysis".to_string(),
title: "Data Analysis".to_string(),
description: "Extract insights from data sets".to_string(),
category: "analysis".to_string(),
icon: "📊".to_string(),
},
PromptData {
id: "creative-writing".to_string(),
title: "Creative Writing".to_string(),
description: "Generate stories and creative content".to_string(),
category: "creative".to_string(),
icon: "🎨".to_string(),
},
PromptData {
id: "email-draft".to_string(),
title: "Email Draft".to_string(),
description: "Compose professional emails".to_string(),
category: "business".to_string(),
icon: "📧".to_string(),
},
];
if category == "all" {
all_prompts
} else {
all_prompts
.into_iter()
.filter(|p| p.category == category)
.collect()
}
}
fn get_templates_data() -> Vec<TemplateData> {
vec![
TemplateData {
name: "Customer Support Bot".to_string(),
description: "Handle customer inquiries automatically".to_string(),
category: "Support".to_string(),
icon: "🎧".to_string(),
},
TemplateData {
name: "FAQ Bot".to_string(),
description: "Answer frequently asked questions".to_string(),
category: "Support".to_string(),
icon: "".to_string(),
},
TemplateData {
name: "Lead Generation Bot".to_string(),
description: "Qualify leads and collect information".to_string(),
category: "Sales".to_string(),
icon: "🎯".to_string(),
},
]
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}