mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Slash commands (#5718)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -94,7 +94,7 @@ pub async fn handle_schedule_add(
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.add_scheduled_job(job).await {
|
||||
match scheduler.add_scheduled_job(job, true).await {
|
||||
Ok(_) => {
|
||||
// The scheduler has copied the recipe to its internal directory.
|
||||
// We can reconstruct the likely path for display if needed, or adjust success message.
|
||||
@@ -175,7 +175,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
match scheduler.remove_scheduled_job(&schedule_id).await {
|
||||
match scheduler.remove_scheduled_job(&schedule_id, true).await {
|
||||
Ok(_) => {
|
||||
println!(
|
||||
"Scheduled job '{}' and its associated recipe removed.",
|
||||
|
||||
@@ -30,7 +30,7 @@ use anyhow::{Context, Result};
|
||||
use completion::GooseCompleter;
|
||||
use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS};
|
||||
use goose::agents::types::RetryConfig;
|
||||
use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGER};
|
||||
use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGERS};
|
||||
use goose::config::{Config, GooseMode};
|
||||
use goose::providers::pricing::initialize_pricing_cache;
|
||||
use goose::session::SessionManager;
|
||||
@@ -703,7 +703,7 @@ impl CliSession {
|
||||
};
|
||||
|
||||
if should_summarize {
|
||||
self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGER));
|
||||
self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGERS[0]));
|
||||
output::show_thinking();
|
||||
self.process_agent_response(true, CancellationToken::default())
|
||||
.await?;
|
||||
|
||||
@@ -23,6 +23,7 @@ use goose::conversation::message::{
|
||||
ToolConfirmationRequest, ToolRequest, ToolResponse,
|
||||
};
|
||||
|
||||
use crate::routes::recipe_utils::RecipeManifest;
|
||||
use crate::routes::reply::MessageEvent;
|
||||
use utoipa::openapi::schema::{
|
||||
AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema,
|
||||
@@ -340,6 +341,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::config_management::read_all_config,
|
||||
super::routes::config_management::providers,
|
||||
super::routes::config_management::get_provider_models,
|
||||
super::routes::config_management::get_slash_commands,
|
||||
super::routes::config_management::upsert_permissions,
|
||||
super::routes::config_management::create_custom_provider,
|
||||
super::routes::config_management::get_custom_provider,
|
||||
@@ -381,6 +383,8 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::recipe::scan_recipe,
|
||||
super::routes::recipe::list_recipes,
|
||||
super::routes::recipe::delete_recipe,
|
||||
super::routes::recipe::schedule_recipe,
|
||||
super::routes::recipe::set_recipe_slash_command,
|
||||
super::routes::recipe::save_recipe,
|
||||
super::routes::recipe::parse_recipe,
|
||||
super::routes::setup::start_openrouter_setup,
|
||||
@@ -392,6 +396,9 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::config_management::ConfigResponse,
|
||||
super::routes::config_management::ProvidersResponse,
|
||||
super::routes::config_management::ProviderDetails,
|
||||
super::routes::config_management::SlashCommandsResponse,
|
||||
super::routes::config_management::SlashCommand,
|
||||
super::routes::config_management::CommandType,
|
||||
super::routes::config_management::ExtensionResponse,
|
||||
super::routes::config_management::ExtensionQuery,
|
||||
super::routes::config_management::ToolPermission,
|
||||
@@ -441,6 +448,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
ExtensionConfig,
|
||||
ConfigKey,
|
||||
Envs,
|
||||
RecipeManifest,
|
||||
ToolSchema,
|
||||
ToolAnnotationsSchema,
|
||||
ToolInfo,
|
||||
@@ -471,8 +479,9 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::recipe::DecodeRecipeResponse,
|
||||
super::routes::recipe::ScanRecipeRequest,
|
||||
super::routes::recipe::ScanRecipeResponse,
|
||||
super::routes::recipe::RecipeManifestResponse,
|
||||
super::routes::recipe::ListRecipeResponse,
|
||||
super::routes::recipe::ScheduleRecipeRequest,
|
||||
super::routes::recipe::SetSlashCommandRequest,
|
||||
super::routes::recipe::DeleteRecipeRequest,
|
||||
super::routes::recipe::SaveRecipeRequest,
|
||||
super::routes::recipe::SaveRecipeResponse,
|
||||
|
||||
@@ -17,7 +17,7 @@ use goose::providers::pricing::{
|
||||
get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing,
|
||||
};
|
||||
use goose::providers::providers as get_providers;
|
||||
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
|
||||
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands};
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -113,6 +113,23 @@ pub enum ConfigValueResponse {
|
||||
MaskedValue(MaskedSecret),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub enum CommandType {
|
||||
Builtin,
|
||||
Recipe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SlashCommand {
|
||||
pub command: String,
|
||||
pub help: String,
|
||||
pub command_type: CommandType,
|
||||
}
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SlashCommandsResponse {
|
||||
pub commands: Vec<SlashCommand>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/upsert",
|
||||
@@ -390,6 +407,30 @@ pub async fn get_provider_models(
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/config/slash_commands",
|
||||
responses(
|
||||
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, StatusCode> {
|
||||
let mut commands: Vec<_> = slash_commands::list_commands()
|
||||
.iter()
|
||||
.map(|command| SlashCommand {
|
||||
command: command.command.clone(),
|
||||
help: command.recipe_path.clone(),
|
||||
command_type: CommandType::Recipe,
|
||||
})
|
||||
.collect();
|
||||
commands.push(SlashCommand {
|
||||
command: "compact".to_string(),
|
||||
help: "Compact the current conversation to save tokens".to_string(),
|
||||
command_type: CommandType::Builtin,
|
||||
});
|
||||
Ok(Json(SlashCommandsResponse { commands }))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PricingData {
|
||||
pub provider: String,
|
||||
@@ -408,8 +449,7 @@ pub struct PricingResponse {
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PricingQuery {
|
||||
/// If true, only return pricing for configured providers. If false, return all.
|
||||
pub configured_only: Option<bool>,
|
||||
pub configured_only: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -423,7 +463,7 @@ pub struct PricingQuery {
|
||||
pub async fn get_pricing(
|
||||
Json(query): Json<PricingQuery>,
|
||||
) -> Result<Json<PricingResponse>, StatusCode> {
|
||||
let configured_only = query.configured_only.unwrap_or(true);
|
||||
let configured_only = query.configured_only;
|
||||
|
||||
// If refresh requested (configured_only = false), refresh the cache
|
||||
if !configured_only {
|
||||
@@ -792,6 +832,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/config/extensions/{name}", delete(remove_extension))
|
||||
.route("/config/providers", get(providers))
|
||||
.route("/config/providers/{name}/models", get(get_provider_models))
|
||||
.route("/config/slash_commands", get(get_slash_commands))
|
||||
.route("/config/pricing", post(get_pricing))
|
||||
.route("/config/init", post(init_config))
|
||||
.route("/config/backup", post(backup_config))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
@@ -8,8 +9,8 @@ use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use goose::recipe::local_recipes;
|
||||
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
|
||||
use goose::recipe::Recipe;
|
||||
use goose::recipe_deeplink;
|
||||
use goose::session::SessionManager;
|
||||
use goose::{recipe_deeplink, slash_commands};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -39,7 +40,7 @@ fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::routes::recipe_utils::{
|
||||
get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe,
|
||||
RecipeValidationError,
|
||||
RecipeManifest, RecipeValidationError,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -114,14 +115,6 @@ pub struct ParseRecipeResponse {
|
||||
pub recipe: Recipe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RecipeManifestResponse {
|
||||
recipe: Recipe,
|
||||
#[serde(rename = "lastModified")]
|
||||
last_modified: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct DeleteRecipeRequest {
|
||||
id: String,
|
||||
@@ -129,7 +122,19 @@ pub struct DeleteRecipeRequest {
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ListRecipeResponse {
|
||||
recipe_manifest_responses: Vec<RecipeManifestResponse>,
|
||||
manifests: Vec<RecipeManifest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ScheduleRecipeRequest {
|
||||
id: String,
|
||||
cron_schedule: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct SetSlashCommandRequest {
|
||||
id: String,
|
||||
slash_command: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -281,26 +286,36 @@ async fn scan_recipe(
|
||||
async fn list_recipes(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ListRecipeResponse>, StatusCode> {
|
||||
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
|
||||
let mut recipe_file_hash_map = HashMap::new();
|
||||
let recipe_manifest_responses = recipe_manifest_with_paths
|
||||
let mut manifests = get_all_recipes_manifests().unwrap_or_default();
|
||||
let recipe_file_hash_map: HashMap<_, _> = manifests
|
||||
.iter()
|
||||
.map(|recipe_manifest_with_path| {
|
||||
let id = &recipe_manifest_with_path.id;
|
||||
let file_path = recipe_manifest_with_path.file_path.clone();
|
||||
recipe_file_hash_map.insert(id.clone(), file_path);
|
||||
RecipeManifestResponse {
|
||||
recipe: recipe_manifest_with_path.recipe.clone(),
|
||||
id: id.clone(),
|
||||
last_modified: recipe_manifest_with_path.last_modified.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<RecipeManifestResponse>>();
|
||||
.map(|m| (m.id.clone(), m.file_path.clone()))
|
||||
.collect();
|
||||
state.set_recipe_file_hash_map(recipe_file_hash_map).await;
|
||||
|
||||
Ok(Json(ListRecipeResponse {
|
||||
recipe_manifest_responses,
|
||||
}))
|
||||
let scheduler = state.scheduler();
|
||||
let scheduled_jobs = scheduler.list_scheduled_jobs().await;
|
||||
let schedule_map: HashMap<_, _> = scheduled_jobs
|
||||
.into_iter()
|
||||
.map(|j| (PathBuf::from(j.source), j.cron))
|
||||
.collect();
|
||||
|
||||
let all_commands = slash_commands::list_commands();
|
||||
let slash_map: HashMap<_, _> = all_commands
|
||||
.into_iter()
|
||||
.map(|sc| (PathBuf::from(sc.recipe_path), sc.command))
|
||||
.collect();
|
||||
|
||||
for manifest in &mut manifests {
|
||||
if let Some(cron) = schedule_map.get(&manifest.file_path) {
|
||||
manifest.schedule_cron = Some(cron.clone());
|
||||
}
|
||||
if let Some(command) = slash_map.get(&manifest.file_path) {
|
||||
manifest.slash_command = Some(command.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ListRecipeResponse { manifests }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -331,6 +346,68 @@ async fn delete_recipe(
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/recipes/schedule",
|
||||
request_body = ScheduleRecipeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Recipe scheduled successfully"),
|
||||
(status = 404, description = "Recipe not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Recipe Management"
|
||||
)]
|
||||
async fn schedule_recipe(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<ScheduleRecipeRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(err.status),
|
||||
};
|
||||
|
||||
let scheduler = state.scheduler();
|
||||
match scheduler
|
||||
.schedule_recipe(file_path, request.cron_schedule)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(StatusCode::OK),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to schedule recipe: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/recipes/slash-command",
|
||||
request_body = SetSlashCommandRequest,
|
||||
responses(
|
||||
(status = 200, description = "Slash command set successfully"),
|
||||
(status = 404, description = "Recipe not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Recipe Management"
|
||||
)]
|
||||
async fn set_recipe_slash_command(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<SetSlashCommandRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(err.status),
|
||||
};
|
||||
|
||||
match slash_commands::set_recipe_slash_command(file_path, request.slash_command) {
|
||||
Ok(_) => Ok(StatusCode::OK),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to set slash command: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/recipes/save",
|
||||
@@ -447,6 +524,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
||||
.route("/recipes/scan", post(scan_recipe))
|
||||
.route("/recipes/list", get(list_recipes))
|
||||
.route("/recipes/delete", post(delete_recipe))
|
||||
.route("/recipes/schedule", post(schedule_recipe))
|
||||
.route("/recipes/slash-command", post(set_recipe_slash_command))
|
||||
.route("/recipes/save", post(save_recipe))
|
||||
.route("/recipes/parse", post(parse_recipe))
|
||||
.with_state(state)
|
||||
|
||||
@@ -5,30 +5,35 @@ use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::routes::errors::ErrorResponse;
|
||||
use crate::state::AppState;
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use goose::agents::Agent;
|
||||
use goose::prompt_template::render_global_file;
|
||||
use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError};
|
||||
use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes};
|
||||
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
|
||||
use goose::recipe::Recipe;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tracing::error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub struct RecipeValidationError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct RecipeManifestWithPath {
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RecipeManifest {
|
||||
pub id: String,
|
||||
pub recipe: Recipe,
|
||||
#[schema(value_type = String)]
|
||||
pub file_path: PathBuf,
|
||||
pub last_modified: String,
|
||||
pub schedule_cron: Option<String>,
|
||||
pub slash_command: Option<String>,
|
||||
}
|
||||
|
||||
pub fn short_id_from_path(path: &str) -> String {
|
||||
@@ -38,7 +43,7 @@ pub fn short_id_from_path(path: &str) -> String {
|
||||
format!("{:016x}", h)
|
||||
}
|
||||
|
||||
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
|
||||
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifest>> {
|
||||
let recipes_with_path = list_local_recipes()?;
|
||||
let mut recipe_manifests_with_path = Vec::new();
|
||||
for (file_path, recipe) in recipes_with_path {
|
||||
@@ -48,11 +53,13 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
|
||||
continue;
|
||||
};
|
||||
|
||||
let manifest_with_path = RecipeManifestWithPath {
|
||||
let manifest_with_path = RecipeManifest {
|
||||
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
|
||||
recipe,
|
||||
file_path,
|
||||
last_modified,
|
||||
schedule_cron: None,
|
||||
slash_command: None,
|
||||
};
|
||||
recipe_manifests_with_path.push(manifest_with_path);
|
||||
}
|
||||
|
||||
@@ -88,10 +88,7 @@ async fn create_schedule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateScheduleRequest>,
|
||||
) -> Result<Json<ScheduledJob>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
tracing::info!(
|
||||
"Server: Calling scheduler.add_scheduled_job() for job '{}'",
|
||||
@@ -108,7 +105,7 @@ async fn create_schedule(
|
||||
process_start_time: None,
|
||||
};
|
||||
scheduler
|
||||
.add_scheduled_job(job.clone())
|
||||
.add_scheduled_job(job.clone(), true)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("Error creating schedule: {:?}", e); // Log error
|
||||
@@ -136,10 +133,7 @@ async fn create_schedule(
|
||||
async fn list_schedules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ListSchedulesResponse>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
tracing::info!("Server: Calling scheduler.list_scheduled_jobs()");
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
@@ -164,17 +158,17 @@ async fn delete_schedule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
let scheduler = state.scheduler();
|
||||
scheduler
|
||||
.remove_scheduled_job(&id, true)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
scheduler.remove_scheduled_job(&id).await.map_err(|e| {
|
||||
eprintln!("Error deleting schedule '{}': {:?}", id, e);
|
||||
match e {
|
||||
goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
})?;
|
||||
.map_err(|e| {
|
||||
eprintln!("Error deleting schedule '{}': {:?}", id, e);
|
||||
match e {
|
||||
goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
})?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -196,10 +190,7 @@ async fn run_now_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<RunNowResponse>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
let (recipe_display_name, recipe_version_opt) = if let Some(job) = scheduler
|
||||
.list_scheduled_jobs()
|
||||
@@ -291,10 +282,7 @@ async fn sessions_handler(
|
||||
Path(schedule_id_param): Path<String>, // Renamed to avoid confusion with session_id
|
||||
Query(query_params): Query<SessionsQuery>,
|
||||
) -> Result<Json<Vec<SessionDisplayInfo>>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
match scheduler
|
||||
.sessions(&schedule_id_param, query_params.limit)
|
||||
@@ -349,10 +337,7 @@ async fn pause_schedule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
scheduler.pause_schedule(&id).await.map_err(|e| {
|
||||
eprintln!("Error pausing schedule '{}': {:?}", id, e);
|
||||
@@ -383,10 +368,7 @@ async fn unpause_schedule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
scheduler.unpause_schedule(&id).await.map_err(|e| {
|
||||
eprintln!("Error unpausing schedule '{}': {:?}", id, e);
|
||||
@@ -419,10 +401,7 @@ async fn update_schedule(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateScheduleRequest>,
|
||||
) -> Result<Json<ScheduledJob>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
scheduler
|
||||
.update_schedule(&id, req.cron)
|
||||
@@ -459,10 +438,7 @@ pub async fn kill_running_job(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<KillJobResponse>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
scheduler.kill_running_job(&id).await.map_err(|e| {
|
||||
eprintln!("Error killing running job '{}': {:?}", id, e);
|
||||
@@ -496,10 +472,7 @@ pub async fn inspect_running_job(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<InspectJobResponse>, StatusCode> {
|
||||
let scheduler = state
|
||||
.scheduler()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let scheduler = state.scheduler();
|
||||
|
||||
match scheduler.get_running_job_info(&id).await {
|
||||
Ok(info) => {
|
||||
|
||||
@@ -26,8 +26,8 @@ impl AppState {
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>, anyhow::Error> {
|
||||
self.agent_manager.scheduler().await
|
||||
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
|
||||
self.agent_manager.scheduler()
|
||||
}
|
||||
|
||||
pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap<String, PathBuf>) {
|
||||
|
||||
@@ -59,14 +59,15 @@ use super::final_output_tool::FinalOutputTool;
|
||||
use super::platform_tools;
|
||||
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
|
||||
use crate::conversation::message::{Message, SystemNotificationType, ToolRequest};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
|
||||
use crate::session::{Session, SessionManager};
|
||||
|
||||
const DEFAULT_MAX_TURNS: u32 = 1000;
|
||||
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
|
||||
pub const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation";
|
||||
pub const MANUAL_COMPACT_TRIGGERS: &[&str] =
|
||||
&["Please compact this conversation", "/compact", "/summarize"];
|
||||
|
||||
/// Context needed for the reply function
|
||||
pub struct ReplyContext {
|
||||
@@ -776,15 +777,35 @@ impl Agent {
|
||||
session_config: SessionConfig,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
|
||||
let is_manual_compact = user_message.content.iter().any(|c| {
|
||||
if let MessageContent::Text(text) = c {
|
||||
text.text.trim() == MANUAL_COMPACT_TRIGGER
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
let message_text = user_message.as_concat_text();
|
||||
let is_manual_compact = MANUAL_COMPACT_TRIGGERS.contains(&message_text.trim());
|
||||
|
||||
let slash_command_recipe = if message_text.trim().starts_with('/') {
|
||||
let command = message_text.split_whitespace().next();
|
||||
command.and_then(crate::slash_commands::resolve_slash_command)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(recipe) = slash_command_recipe {
|
||||
let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let prompt_message = Message::user()
|
||||
.with_text(prompt)
|
||||
.with_visibility(false, true);
|
||||
SessionManager::add_message(&session_config.id, &prompt_message).await?;
|
||||
SessionManager::add_message(
|
||||
&session_config.id,
|
||||
&user_message.with_visibility(true, false),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
SessionManager::add_message(&session_config.id, &user_message).await?;
|
||||
}
|
||||
|
||||
SessionManager::add_message(&session_config.id, &user_message).await?;
|
||||
let session = SessionManager::get_session(&session_config.id, true).await?;
|
||||
|
||||
let conversation = session
|
||||
|
||||
@@ -26,7 +26,7 @@ mod tool_route_manager;
|
||||
mod tool_router_index_manager;
|
||||
pub mod types;
|
||||
|
||||
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGER};
|
||||
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS};
|
||||
pub use extension::ExtensionConfig;
|
||||
pub use extension_manager::ExtensionManager;
|
||||
pub use prompt_manager::PromptManager;
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Agent {
|
||||
process_start_time: None,
|
||||
};
|
||||
|
||||
match scheduler.add_scheduled_job(job).await {
|
||||
match scheduler.add_scheduled_job(job, true).await {
|
||||
Ok(()) => Ok(vec![Content::text(format!(
|
||||
"Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode",
|
||||
job_id, recipe_path, cron_expression, execution_mode
|
||||
@@ -284,7 +284,7 @@ impl Agent {
|
||||
)
|
||||
})?;
|
||||
|
||||
match scheduler.remove_scheduled_job(job_id).await {
|
||||
match scheduler.remove_scheduled_job(job_id, true).await {
|
||||
Ok(()) => Ok(vec![Content::text(format!(
|
||||
"Successfully deleted job '{}'",
|
||||
job_id
|
||||
|
||||
@@ -59,8 +59,8 @@ impl AgentManager {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>> {
|
||||
Ok(Arc::clone(&self.scheduler))
|
||||
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
|
||||
Arc::clone(&self.scheduler)
|
||||
}
|
||||
|
||||
pub async fn set_default_provider(&self, provider: Arc<dyn crate::providers::base::Provider>) {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod scheduler_trait;
|
||||
pub mod security;
|
||||
pub mod session;
|
||||
pub mod session_context;
|
||||
pub mod slash_commands;
|
||||
pub mod subprocess;
|
||||
pub mod token_counter;
|
||||
pub mod tool_inspection;
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn get_recipe_library_dir(is_global: bool) -> PathBuf {
|
||||
if is_global {
|
||||
Paths::config_dir().join("recipes")
|
||||
} else {
|
||||
std::env::current_dir().unwrap().join(".goose/recipes")
|
||||
env::current_dir().unwrap().join(".goose/recipes")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-32
@@ -269,6 +269,7 @@ impl Scheduler {
|
||||
pub async fn add_scheduled_job(
|
||||
&self,
|
||||
original_job_spec: ScheduledJob,
|
||||
make_copy: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
{
|
||||
let jobs_guard = self.jobs.lock().await;
|
||||
@@ -277,29 +278,30 @@ impl Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
let original_recipe_path = Path::new(&original_job_spec.source);
|
||||
if !original_recipe_path.is_file() {
|
||||
return Err(SchedulerError::RecipeLoadError(format!(
|
||||
"Recipe file not found: {}",
|
||||
original_job_spec.source
|
||||
)));
|
||||
}
|
||||
|
||||
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?;
|
||||
let original_extension = original_recipe_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("yaml");
|
||||
|
||||
let destination_filename = format!("{}.{}", original_job_spec.id, original_extension);
|
||||
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
|
||||
|
||||
fs::copy(original_recipe_path, &destination_recipe_path)?;
|
||||
|
||||
let mut stored_job = original_job_spec;
|
||||
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
|
||||
stored_job.current_session_id = None;
|
||||
stored_job.process_start_time = None;
|
||||
if make_copy {
|
||||
let original_recipe_path = Path::new(&stored_job.source);
|
||||
if !original_recipe_path.is_file() {
|
||||
return Err(SchedulerError::RecipeLoadError(format!(
|
||||
"Recipe file not found: {}",
|
||||
stored_job.source
|
||||
)));
|
||||
}
|
||||
|
||||
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?;
|
||||
let original_extension = original_recipe_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("yaml");
|
||||
|
||||
let destination_filename = format!("{}.{}", stored_job.id, original_extension);
|
||||
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
|
||||
|
||||
fs::copy(original_recipe_path, &destination_recipe_path)?;
|
||||
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
|
||||
stored_job.current_session_id = None;
|
||||
stored_job.process_start_time = None;
|
||||
}
|
||||
|
||||
let cron_task = self.create_cron_task(stored_job.clone())?;
|
||||
|
||||
@@ -318,6 +320,69 @@ impl Scheduler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn schedule_recipe(
|
||||
&self,
|
||||
recipe_path: PathBuf,
|
||||
cron_schedule: Option<String>,
|
||||
) -> Result<(), SchedulerError> {
|
||||
let recipe_path_str = recipe_path.to_string_lossy().to_string();
|
||||
|
||||
let existing_job_id = {
|
||||
let jobs_guard = self.jobs.lock().await;
|
||||
jobs_guard
|
||||
.iter()
|
||||
.find(|(_, (_, job))| job.source == recipe_path_str)
|
||||
.map(|(id, _)| id.clone())
|
||||
};
|
||||
|
||||
match cron_schedule {
|
||||
Some(cron) => {
|
||||
if let Some(job_id) = existing_job_id {
|
||||
self.update_schedule(&job_id, cron).await
|
||||
} else {
|
||||
let job_id = self.generate_unique_job_id(&recipe_path).await;
|
||||
let job = ScheduledJob {
|
||||
id: job_id,
|
||||
source: recipe_path_str,
|
||||
cron,
|
||||
last_run: None,
|
||||
currently_running: false,
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
};
|
||||
self.add_scheduled_job(job, false).await
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(job_id) = existing_job_id {
|
||||
self.remove_scheduled_job(&job_id, false).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_unique_job_id(&self, path: &Path) -> String {
|
||||
let base_id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unnamed")
|
||||
.to_string();
|
||||
|
||||
let jobs_guard = self.jobs.lock().await;
|
||||
let mut id = base_id.clone();
|
||||
let mut counter = 1;
|
||||
|
||||
while jobs_guard.contains_key(&id) {
|
||||
id = format!("{}_{}", base_id, counter);
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
async fn load_jobs_from_storage(self: &Arc<Self>) {
|
||||
if !self.storage_path.exists() {
|
||||
return;
|
||||
@@ -395,7 +460,11 @@ impl Scheduler {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
pub async fn remove_scheduled_job(
|
||||
&self,
|
||||
id: &str,
|
||||
remove_recipe: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
let (job_uuid, recipe_path) = {
|
||||
let mut jobs_guard = self.jobs.lock().await;
|
||||
match jobs_guard.remove(id) {
|
||||
@@ -409,9 +478,11 @@ impl Scheduler {
|
||||
.await
|
||||
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
|
||||
|
||||
let path = Path::new(&recipe_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
if remove_recipe {
|
||||
let path = Path::new(&recipe_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
persist_jobs(&self.storage_path, &self.jobs).await?;
|
||||
@@ -733,16 +804,32 @@ async fn execute_job(
|
||||
|
||||
#[async_trait]
|
||||
impl SchedulerTrait for Scheduler {
|
||||
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
|
||||
self.add_scheduled_job(job).await
|
||||
async fn add_scheduled_job(
|
||||
&self,
|
||||
job: ScheduledJob,
|
||||
make_copy: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
self.add_scheduled_job(job, make_copy).await
|
||||
}
|
||||
|
||||
async fn schedule_recipe(
|
||||
&self,
|
||||
recipe_path: PathBuf,
|
||||
cron_schedule: Option<String>,
|
||||
) -> Result<(), SchedulerError> {
|
||||
self.schedule_recipe(recipe_path, cron_schedule).await
|
||||
}
|
||||
|
||||
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
|
||||
self.list_scheduled_jobs().await
|
||||
}
|
||||
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
self.remove_scheduled_job(id).await
|
||||
async fn remove_scheduled_job(
|
||||
&self,
|
||||
id: &str,
|
||||
remove_recipe: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
self.remove_scheduled_job(id, remove_recipe).await
|
||||
}
|
||||
|
||||
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
@@ -815,7 +902,7 @@ mod tests {
|
||||
process_start_time: None,
|
||||
};
|
||||
|
||||
scheduler.add_scheduled_job(job).await.unwrap();
|
||||
scheduler.add_scheduled_job(job, true).await.unwrap();
|
||||
sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
@@ -840,7 +927,7 @@ mod tests {
|
||||
process_start_time: None,
|
||||
};
|
||||
|
||||
scheduler.add_scheduled_job(job).await.unwrap();
|
||||
scheduler.add_scheduled_job(job, true).await.unwrap();
|
||||
scheduler.pause_schedule("paused_job").await.unwrap();
|
||||
sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::scheduler::{ScheduledJob, SchedulerError};
|
||||
use crate::session::Session;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SchedulerTrait: Send + Sync {
|
||||
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError>;
|
||||
async fn add_scheduled_job(
|
||||
&self,
|
||||
job: ScheduledJob,
|
||||
copy_recipe: bool,
|
||||
) -> Result<(), SchedulerError>;
|
||||
async fn schedule_recipe(
|
||||
&self,
|
||||
recipe_path: PathBuf,
|
||||
cron_schedule: Option<String>,
|
||||
) -> anyhow::Result<(), SchedulerError>;
|
||||
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob>;
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
async fn remove_scheduled_job(
|
||||
&self,
|
||||
id: &str,
|
||||
remove_recipe: bool,
|
||||
) -> Result<(), SchedulerError>;
|
||||
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
async fn run_now(&self, id: &str) -> Result<String, SchedulerError>;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
const SLASH_COMMANDS_CONFIG_KEY: &str = "slash_commands";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlashCommandMapping {
|
||||
pub command: String,
|
||||
pub recipe_path: String,
|
||||
}
|
||||
|
||||
pub fn list_commands() -> Vec<SlashCommandMapping> {
|
||||
Config::global()
|
||||
.get_param(SLASH_COMMANDS_CONFIG_KEY)
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"Failed to load {}: {}. Falling back to empty list.",
|
||||
SLASH_COMMANDS_CONFIG_KEY, err
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
fn save_slash_commands(commands: Vec<SlashCommandMapping>) -> Result<()> {
|
||||
Config::global()
|
||||
.set_param(SLASH_COMMANDS_CONFIG_KEY, &commands)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save slash commands: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_recipe_slash_command(recipe_path: PathBuf, command: Option<String>) -> Result<()> {
|
||||
let recipe_path_str = recipe_path.to_string_lossy().to_string();
|
||||
|
||||
let mut commands = list_commands();
|
||||
commands.retain(|mapping| mapping.recipe_path != recipe_path_str);
|
||||
|
||||
if let Some(cmd) = command {
|
||||
let normalized_cmd = cmd.trim_start_matches('/').to_lowercase();
|
||||
if !normalized_cmd.is_empty() {
|
||||
commands.push(SlashCommandMapping {
|
||||
command: normalized_cmd,
|
||||
recipe_path: recipe_path_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
save_slash_commands(commands)
|
||||
}
|
||||
|
||||
pub fn get_recipe_for_command(command: &str) -> Option<PathBuf> {
|
||||
let normalized = command.trim_start_matches('/').to_lowercase();
|
||||
let commands = list_commands();
|
||||
commands
|
||||
.into_iter()
|
||||
.find(|mapping| mapping.command == normalized)
|
||||
.map(|mapping| PathBuf::from(mapping.recipe_path))
|
||||
}
|
||||
|
||||
pub fn resolve_slash_command(command: &str) -> Option<Recipe> {
|
||||
let recipe_path = get_recipe_for_command(command)?;
|
||||
|
||||
if !recipe_path.exists() {
|
||||
return None;
|
||||
}
|
||||
let recipe_content = std::fs::read_to_string(&recipe_path).ok()?;
|
||||
let recipe = Recipe::from_content(&recipe_content).ok()?;
|
||||
|
||||
Some(recipe)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod tests {
|
||||
use goose::scheduler::{ScheduledJob, SchedulerError};
|
||||
use goose::scheduler_trait::SchedulerTrait;
|
||||
use goose::session::Session;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct MockScheduler {
|
||||
@@ -34,18 +35,34 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl SchedulerTrait for MockScheduler {
|
||||
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
|
||||
async fn add_scheduled_job(
|
||||
&self,
|
||||
job: ScheduledJob,
|
||||
_copy: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
jobs.push(job);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_recipe(
|
||||
&self,
|
||||
_recipe_path: PathBuf,
|
||||
_cron_schedule: Option<String>,
|
||||
) -> Result<(), SchedulerError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
|
||||
let jobs = self.jobs.lock().await;
|
||||
jobs.clone()
|
||||
}
|
||||
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
async fn remove_scheduled_job(
|
||||
&self,
|
||||
id: &str,
|
||||
_remove: bool,
|
||||
) -> Result<(), SchedulerError> {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
if let Some(pos) = jobs.iter().position(|job| job.id == id) {
|
||||
jobs.remove(pos);
|
||||
|
||||
+167
-7
@@ -878,6 +878,26 @@
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/config/slash_commands": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "get_slash_commands",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slash commands retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SlashCommandsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/upsert": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1372,6 +1392,64 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/recipes/schedule": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Recipe Management"
|
||||
],
|
||||
"operationId": "schedule_recipe",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ScheduleRecipeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Recipe scheduled successfully"
|
||||
},
|
||||
"404": {
|
||||
"description": "Recipe not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/recipes/slash-command": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Recipe Management"
|
||||
],
|
||||
"operationId": "set_recipe_slash_command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetSlashCommandRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slash command set successfully"
|
||||
},
|
||||
"404": {
|
||||
"description": "Recipe not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/reply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2227,6 +2305,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CommandType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Builtin",
|
||||
"Recipe"
|
||||
]
|
||||
},
|
||||
"ConfigKey": {
|
||||
"type": "object",
|
||||
"description": "Configuration key metadata for provider setup",
|
||||
@@ -3077,13 +3162,13 @@
|
||||
"ListRecipeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"recipe_manifest_responses"
|
||||
"manifests"
|
||||
],
|
||||
"properties": {
|
||||
"recipe_manifest_responses": {
|
||||
"manifests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RecipeManifestResponse"
|
||||
"$ref": "#/components/schemas/RecipeManifest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3897,22 +3982,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecipeManifestResponse": {
|
||||
"RecipeManifest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"recipe",
|
||||
"lastModified",
|
||||
"id"
|
||||
"file_path",
|
||||
"last_modified"
|
||||
],
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastModified": {
|
||||
"last_modified": {
|
||||
"type": "string"
|
||||
},
|
||||
"recipe": {
|
||||
"$ref": "#/components/schemas/Recipe"
|
||||
},
|
||||
"schedule_cron": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"slash_command": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4177,6 +4274,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScheduleRecipeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"cron_schedule": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScheduledJob": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -4447,6 +4559,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetSlashCommandRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"slash_command": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4480,6 +4607,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SlashCommand": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command",
|
||||
"help",
|
||||
"command_type"
|
||||
],
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"command_type": {
|
||||
"$ref": "#/components/schemas/CommandType"
|
||||
},
|
||||
"help": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SlashCommandsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"commands"
|
||||
],
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SlashCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"StartAgentRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
@@ -260,6 +260,13 @@ export const setConfigProvider = <ThrowOnError extends boolean = false>(options:
|
||||
});
|
||||
};
|
||||
|
||||
export const getSlashCommands = <ThrowOnError extends boolean = false>(options?: Options<GetSlashCommandsData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetSlashCommandsResponses, unknown, ThrowOnError>({
|
||||
url: '/config/slash_commands',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertConfig = <ThrowOnError extends boolean = false>(options: Options<UpsertConfigData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<UpsertConfigResponses, UpsertConfigErrors, ThrowOnError>({
|
||||
url: '/config/upsert',
|
||||
@@ -401,6 +408,28 @@ export const scanRecipe = <ThrowOnError extends boolean = false>(options: Option
|
||||
});
|
||||
};
|
||||
|
||||
export const scheduleRecipe = <ThrowOnError extends boolean = false>(options: Options<ScheduleRecipeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<ScheduleRecipeResponses, ScheduleRecipeErrors, ThrowOnError>({
|
||||
url: '/recipes/schedule',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const setRecipeSlashCommand = <ThrowOnError extends boolean = false>(options: Options<SetRecipeSlashCommandData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<SetRecipeSlashCommandResponses, SetRecipeSlashCommandErrors, ThrowOnError>({
|
||||
url: '/recipes/slash-command',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const reply = <ThrowOnError extends boolean = false>(options: Options<ReplyData, ThrowOnError>) => {
|
||||
return (options.client ?? client).sse.post<ReplyResponses, ReplyErrors, ThrowOnError>({
|
||||
url: '/reply',
|
||||
|
||||
@@ -36,6 +36,8 @@ export type CheckProviderRequest = {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export type CommandType = 'Builtin' | 'Recipe';
|
||||
|
||||
/**
|
||||
* Configuration key metadata for provider setup
|
||||
*/
|
||||
@@ -322,7 +324,7 @@ export type KillJobResponse = {
|
||||
};
|
||||
|
||||
export type ListRecipeResponse = {
|
||||
recipe_manifest_responses: Array<RecipeManifestResponse>;
|
||||
manifests: Array<RecipeManifest>;
|
||||
};
|
||||
|
||||
export type ListSchedulesResponse = {
|
||||
@@ -564,10 +566,13 @@ export type Recipe = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type RecipeManifestResponse = {
|
||||
export type RecipeManifest = {
|
||||
file_path: string;
|
||||
id: string;
|
||||
lastModified: string;
|
||||
last_modified: string;
|
||||
recipe: Recipe;
|
||||
schedule_cron?: string | null;
|
||||
slash_command?: string | null;
|
||||
};
|
||||
|
||||
export type RecipeParameter = {
|
||||
@@ -666,6 +671,11 @@ export type ScanRecipeResponse = {
|
||||
has_security_warnings: boolean;
|
||||
};
|
||||
|
||||
export type ScheduleRecipeRequest = {
|
||||
cron_schedule?: string | null;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ScheduledJob = {
|
||||
cron: string;
|
||||
current_session_id?: string | null;
|
||||
@@ -739,6 +749,11 @@ export type SetProviderRequest = {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export type SetSlashCommandRequest = {
|
||||
id: string;
|
||||
slash_command?: string | null;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
goose_model?: string | null;
|
||||
goose_provider?: string | null;
|
||||
@@ -750,6 +765,16 @@ export type SetupResponse = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type SlashCommand = {
|
||||
command: string;
|
||||
command_type: CommandType;
|
||||
help: string;
|
||||
};
|
||||
|
||||
export type SlashCommandsResponse = {
|
||||
commands: Array<SlashCommand>;
|
||||
};
|
||||
|
||||
export type StartAgentRequest = {
|
||||
recipe?: Recipe | null;
|
||||
recipe_deeplink?: string | null;
|
||||
@@ -1604,6 +1629,22 @@ export type SetConfigProviderData = {
|
||||
url: '/config/set_provider';
|
||||
};
|
||||
|
||||
export type GetSlashCommandsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/slash_commands';
|
||||
};
|
||||
|
||||
export type GetSlashCommandsResponses = {
|
||||
/**
|
||||
* Slash commands retrieved successfully
|
||||
*/
|
||||
200: SlashCommandsResponse;
|
||||
};
|
||||
|
||||
export type GetSlashCommandsResponse = GetSlashCommandsResponses[keyof GetSlashCommandsResponses];
|
||||
|
||||
export type UpsertConfigData = {
|
||||
body: UpsertConfigQuery;
|
||||
path?: never;
|
||||
@@ -1965,6 +2006,56 @@ export type ScanRecipeResponses = {
|
||||
|
||||
export type ScanRecipeResponse2 = ScanRecipeResponses[keyof ScanRecipeResponses];
|
||||
|
||||
export type ScheduleRecipeData = {
|
||||
body: ScheduleRecipeRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/recipes/schedule';
|
||||
};
|
||||
|
||||
export type ScheduleRecipeErrors = {
|
||||
/**
|
||||
* Recipe not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ScheduleRecipeResponses = {
|
||||
/**
|
||||
* Recipe scheduled successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type SetRecipeSlashCommandData = {
|
||||
body: SetSlashCommandRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/recipes/slash-command';
|
||||
};
|
||||
|
||||
export type SetRecipeSlashCommandErrors = {
|
||||
/**
|
||||
* Recipe not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type SetRecipeSlashCommandResponses = {
|
||||
/**
|
||||
* Slash command set successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type ReplyData = {
|
||||
body: ChatRequest;
|
||||
path?: never;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useModelAndProvider } from './ModelAndProviderContext';
|
||||
import { useWhisper } from '../hooks/useWhisper';
|
||||
import { WaveformVisualizer } from './WaveformVisualizer';
|
||||
import { toastError } from '../toasts';
|
||||
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
|
||||
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
|
||||
import { useDictationSettings } from '../hooks/useDictationSettings';
|
||||
import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates';
|
||||
import { CostTracker } from './bottom_menu/CostTracker';
|
||||
@@ -215,15 +215,17 @@ export default function ChatInput({
|
||||
query: string;
|
||||
mentionStart: number;
|
||||
selectedIndex: number;
|
||||
isSlashCommand: boolean;
|
||||
}>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
query: '',
|
||||
mentionStart: -1,
|
||||
selectedIndex: 0,
|
||||
isSlashCommand: false,
|
||||
});
|
||||
const mentionPopoverRef = useRef<{
|
||||
getDisplayFiles: () => FileItemWithMatch[];
|
||||
getDisplayFiles: () => DisplayItemWithMatch[];
|
||||
selectFile: (index: number) => void;
|
||||
}>(null);
|
||||
|
||||
@@ -563,13 +565,17 @@ export default function ChatInput({
|
||||
setDisplayValue(val);
|
||||
updateValue(val);
|
||||
setHasUserTyped(true);
|
||||
checkForMention(val, cursorPosition, evt.target);
|
||||
checkForMentionOrSlash(val, cursorPosition, evt.target);
|
||||
};
|
||||
|
||||
const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => {
|
||||
// Find the last @ before the cursor
|
||||
const checkForMentionOrSlash = (
|
||||
text: string,
|
||||
cursorPosition: number,
|
||||
textArea: HTMLTextAreaElement
|
||||
) => {
|
||||
const isSlashCommand = text.startsWith('/');
|
||||
const beforeCursor = text.slice(0, cursorPosition);
|
||||
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
||||
const lastAtIndex = isSlashCommand ? 0 : beforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
// No @ found, close mention popover
|
||||
@@ -597,6 +603,7 @@ export default function ChatInput({
|
||||
query: afterAt,
|
||||
mentionStart: lastAtIndex,
|
||||
selectedIndex: 0, // Reset selection when query changes
|
||||
isSlashCommand,
|
||||
// filteredFiles will be populated by the MentionPopover component
|
||||
}));
|
||||
};
|
||||
@@ -1024,13 +1031,13 @@ export default function ChatInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMentionFileSelect = (filePath: string) => {
|
||||
const handleMentionItemSelect = (itemText: string) => {
|
||||
// Replace the @ mention with the file path
|
||||
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
|
||||
const afterMention = displayValue.slice(
|
||||
mentionPopover.mentionStart + 1 + mentionPopover.query.length
|
||||
);
|
||||
const newValue = `${beforeMention}${filePath}${afterMention}`;
|
||||
const newValue = `${beforeMention}${itemText}${afterMention}`;
|
||||
|
||||
setDisplayValue(newValue);
|
||||
setValue(newValue);
|
||||
@@ -1040,7 +1047,7 @@ export default function ChatInput({
|
||||
// Set cursor position after the inserted file path
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
const newCursorPosition = beforeMention.length + filePath.length;
|
||||
const newCursorPosition = beforeMention.length + itemText.length;
|
||||
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
}
|
||||
}, 0);
|
||||
@@ -1453,7 +1460,6 @@ export default function ChatInput({
|
||||
{/* Directory path */}
|
||||
<DirSwitcher className="mr-0" />
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1468,9 +1474,7 @@ export default function ChatInput({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Attach file or directory</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
|
||||
{/* Model selector, mode selector, alerts, summarize button */}
|
||||
<div className="flex flex-row items-center">
|
||||
{/* Cost Tracker */}
|
||||
@@ -1538,7 +1542,6 @@ export default function ChatInput({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionId && diagnosticsOpen && (
|
||||
<DiagnosticsModal
|
||||
isOpen={diagnosticsOpen}
|
||||
@@ -1546,12 +1549,12 @@ export default function ChatInput({
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MentionPopover
|
||||
ref={mentionPopoverRef}
|
||||
isOpen={mentionPopover.isOpen}
|
||||
isSlashCommand={mentionPopover.isSlashCommand}
|
||||
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
|
||||
onSelect={handleMentionFileSelect}
|
||||
onSelect={handleMentionItemSelect}
|
||||
position={mentionPopover.position}
|
||||
query={mentionPopover.query}
|
||||
selectedIndex={mentionPopover.selectedIndex}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FileIconProps {
|
||||
fileName: string;
|
||||
isDirectory: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileIcon: React.FC<FileIconProps> = ({
|
||||
fileName,
|
||||
isDirectory,
|
||||
className = 'w-4 h-4',
|
||||
}) => {
|
||||
if (isDirectory) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
|
||||
// Image files
|
||||
if (
|
||||
['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21,15 16,10 5,21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Video files
|
||||
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio files
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Archive/compressed files
|
||||
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z" />
|
||||
<path d="M10 7h4" />
|
||||
<path d="M10 11h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF files
|
||||
if (ext === 'pdf') {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M10 12h4" />
|
||||
<path d="M10 16h2" />
|
||||
<path d="M10 8h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Design files
|
||||
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<path d="M9 9h6v6h-6z" />
|
||||
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// JavaScript/TypeScript files
|
||||
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M10 13l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Python files
|
||||
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<circle cx="14" cy="16" r="2" />
|
||||
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// HTML files
|
||||
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<polyline points="9,13 9,17 15,17 15,13" />
|
||||
<line x1="12" y1="13" x2="12" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// CSS files
|
||||
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h8" />
|
||||
<circle cx="12" cy="15" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// JSON/Data files
|
||||
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
|
||||
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Markdown files
|
||||
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10,9 9,9 8,9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Database files
|
||||
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Configuration files
|
||||
if (
|
||||
[
|
||||
'env',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'config',
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
].includes(ext || '') ||
|
||||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Text files
|
||||
if (
|
||||
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10,9 9,9 8,9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Executable files
|
||||
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<path d="M10 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Script files
|
||||
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Default file icon
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10,9 9,9 8,9" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
Image,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
FileText,
|
||||
Palette,
|
||||
Code,
|
||||
Database,
|
||||
Settings,
|
||||
Terminal,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { DisplayItem } from './MentionPopover';
|
||||
|
||||
interface FileIconProps {
|
||||
item: DisplayItem;
|
||||
}
|
||||
|
||||
interface IconInfo {
|
||||
Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const getItemIcon = (item: DisplayItem): IconInfo => {
|
||||
switch (item.itemType) {
|
||||
case 'Builtin':
|
||||
return { Icon: Zap, color: '#3b82f6' }; // Blue
|
||||
case 'Recipe':
|
||||
return { Icon: BookOpen, color: '#10b981' }; // Green
|
||||
case 'Directory':
|
||||
return { Icon: Folder, color: '#f59e0b' }; // Amber
|
||||
default: {
|
||||
const ext = item.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Image files
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext)) {
|
||||
return { Icon: Image, color: '#8b5cf6' }; // Purple
|
||||
}
|
||||
|
||||
// Video files
|
||||
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
||||
return { Icon: Video, color: '#ef4444' }; // Red
|
||||
}
|
||||
|
||||
// Audio files
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext)) {
|
||||
return { Icon: Music, color: '#f97316' }; // Orange
|
||||
}
|
||||
|
||||
// Archive/compressed files
|
||||
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext)) {
|
||||
return { Icon: Archive, color: '#6b7280' }; // Gray
|
||||
}
|
||||
|
||||
// PDF files
|
||||
if (ext === 'pdf') {
|
||||
return { Icon: FileText, color: '#dc2626' }; // Red
|
||||
}
|
||||
|
||||
// Design files
|
||||
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext)) {
|
||||
return { Icon: Palette, color: '#ec4899' }; // Pink
|
||||
}
|
||||
|
||||
// JavaScript/TypeScript files
|
||||
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext)) {
|
||||
return { Icon: Code, color: '#eab308' }; // Yellow
|
||||
}
|
||||
|
||||
// Python files
|
||||
if (['py', 'pyw', 'pyc'].includes(ext)) {
|
||||
return { Icon: Code, color: '#3b82f6' }; // Blue
|
||||
}
|
||||
|
||||
// HTML files
|
||||
if (['html', 'htm', 'xhtml'].includes(ext)) {
|
||||
return { Icon: Code, color: '#f97316' }; // Orange
|
||||
}
|
||||
|
||||
// CSS files
|
||||
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext)) {
|
||||
return { Icon: Code, color: '#06b6d4' }; // Cyan
|
||||
}
|
||||
|
||||
// JSON/Data files
|
||||
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext)) {
|
||||
return { Icon: FileText, color: '#10b981' }; // Green
|
||||
}
|
||||
|
||||
// Markdown files
|
||||
if (['md', 'markdown', 'mdx'].includes(ext)) {
|
||||
return { Icon: FileText, color: '#6366f1' }; // Indigo
|
||||
}
|
||||
|
||||
// Database files
|
||||
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext)) {
|
||||
return { Icon: Database, color: '#059669' }; // Emerald
|
||||
}
|
||||
|
||||
// Configuration files
|
||||
if (
|
||||
[
|
||||
'env',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'config',
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
].includes(ext || '') ||
|
||||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(item.name.toLowerCase())
|
||||
) {
|
||||
return { Icon: Settings, color: '#6b7280' }; // Gray
|
||||
}
|
||||
|
||||
// Text files
|
||||
if (
|
||||
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||
['readme', 'license', 'changelog', 'contributing'].includes(item.name.toLowerCase())
|
||||
) {
|
||||
return { Icon: FileText, color: '#374151' }; // Dark gray
|
||||
}
|
||||
|
||||
// Executable files
|
||||
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
||||
return { Icon: Wrench, color: '#7c3aed' }; // Purple
|
||||
}
|
||||
|
||||
// Script files
|
||||
if (
|
||||
['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')
|
||||
) {
|
||||
return { Icon: Terminal, color: '#059669' }; // Emerald
|
||||
}
|
||||
|
||||
// Default file icon
|
||||
return { Icon: File, color: '#6b7280' }; // Gray
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ItemIcon: React.FC<FileIconProps> = ({ item }) => {
|
||||
const { Icon, color } = getItemIcon(item);
|
||||
|
||||
return <Icon className="w-4 h-4" style={{ color }} />;
|
||||
};
|
||||
@@ -1,22 +1,32 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FileIcon } from './FileIcon';
|
||||
import { ItemIcon } from './ItemIcon';
|
||||
import { CommandType, getSlashCommands } from '../api';
|
||||
|
||||
interface FileItem {
|
||||
path: string;
|
||||
type DisplayItemType = CommandType | 'Directory' | 'File';
|
||||
|
||||
const typeOrder: Record<DisplayItemType, number> = {
|
||||
Directory: 0,
|
||||
File: 1,
|
||||
Builtin: 2,
|
||||
Recipe: 3,
|
||||
};
|
||||
|
||||
export interface DisplayItem {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
extra: string;
|
||||
itemType: DisplayItemType;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface FileItemWithMatch extends FileItem {
|
||||
export interface DisplayItemWithMatch extends DisplayItem {
|
||||
matchScore: number;
|
||||
matches: number[];
|
||||
matchedText: string;
|
||||
@@ -28,6 +38,7 @@ interface MentionPopoverProps {
|
||||
onSelect: (filePath: string) => void;
|
||||
position: { x: number; y: number };
|
||||
query: string;
|
||||
isSlashCommand: boolean;
|
||||
selectedIndex: number;
|
||||
onSelectedIndexChange: (index: number) => void;
|
||||
}
|
||||
@@ -97,440 +108,464 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
|
||||
};
|
||||
|
||||
const MentionPopover = forwardRef<
|
||||
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
|
||||
{ getDisplayFiles: () => DisplayItemWithMatch[]; selectFile: (index: number) => void },
|
||||
MentionPopoverProps
|
||||
>(({ isOpen, onClose, onSelect, position, query, selectedIndex, onSelectedIndexChange }, ref) => {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
>(
|
||||
(
|
||||
{
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
position,
|
||||
query,
|
||||
isSlashCommand,
|
||||
selectedIndex,
|
||||
onSelectedIndexChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [items, setItems] = useState<DisplayItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
|
||||
const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
|
||||
|
||||
const compareByType = (a: FileItemWithMatch, b: FileItemWithMatch) =>
|
||||
a.isDirectory !== b.isDirectory ? (a.isDirectory ? 1 : -1) : 0;
|
||||
const scanDirectoryFromRoot = useCallback(
|
||||
async (dirPath: string, relativePath = '', depth = 0): Promise<DisplayItem[]> => {
|
||||
// Increase depth limit for better file discovery
|
||||
if (depth > 5) return [];
|
||||
|
||||
// Filter and sort files based on query
|
||||
const displayFiles = useMemo((): FileItemWithMatch[] => {
|
||||
if (!query.trim()) {
|
||||
return files
|
||||
.map((file) => ({
|
||||
...file,
|
||||
matchScore: 0,
|
||||
matches: [],
|
||||
matchedText: file.name,
|
||||
depth: currentWorkingDir
|
||||
? file.path.replace(currentWorkingDir, '').split('/').length - 1
|
||||
: 0,
|
||||
}))
|
||||
try {
|
||||
const items = await window.electron.listFiles(dirPath);
|
||||
const results: DisplayItem[] = [];
|
||||
|
||||
// Common directories to prioritize or skip
|
||||
const priorityDirs = [
|
||||
'Desktop',
|
||||
'Documents',
|
||||
'Downloads',
|
||||
'Projects',
|
||||
'Development',
|
||||
'Code',
|
||||
'src',
|
||||
'components',
|
||||
'icons',
|
||||
];
|
||||
const skipDirs = [
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'.cache',
|
||||
'.npm',
|
||||
'.yarn',
|
||||
'Library',
|
||||
'System',
|
||||
'Applications',
|
||||
'.Trash',
|
||||
];
|
||||
|
||||
// Don't skip as many directories at deeper levels to find more items
|
||||
const skipDirsAtDepth =
|
||||
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||
|
||||
// Sort items to prioritize certain directories
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
const aPriority = priorityDirs.includes(a);
|
||||
const bPriority = priorityDirs.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Increase item limit per directory for better coverage
|
||||
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
|
||||
|
||||
for (const item of sortedItems.slice(0, itemLimit)) {
|
||||
const fullPath = `${dirPath}/${item}`;
|
||||
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
||||
|
||||
// Skip hidden items and common ignore patterns
|
||||
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First, check if this looks like a file based on extension
|
||||
const hasExtension = item.includes('.');
|
||||
const ext = item.split('.').pop()?.toLowerCase();
|
||||
const commonExtensions = [
|
||||
// Code items
|
||||
'txt',
|
||||
'md',
|
||||
'js',
|
||||
'ts',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'py',
|
||||
'java',
|
||||
'cpp',
|
||||
'c',
|
||||
'h',
|
||||
'css',
|
||||
'html',
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'php',
|
||||
'sql',
|
||||
'r',
|
||||
'scala',
|
||||
'swift',
|
||||
'kt',
|
||||
'dart',
|
||||
'vue',
|
||||
'svelte',
|
||||
'astro',
|
||||
'scss',
|
||||
'less',
|
||||
// Documentation
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
// Config items
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
// Images and assets
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'ico',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
// Vector and design items
|
||||
'ai',
|
||||
'eps',
|
||||
'sketch',
|
||||
'fig',
|
||||
'xd',
|
||||
'psd',
|
||||
// Other common items
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
];
|
||||
|
||||
// If it has a known file extension, treat it as a file
|
||||
if (hasExtension && ext && commonExtensions.includes(ext)) {
|
||||
results.push({
|
||||
extra: fullPath,
|
||||
name: item,
|
||||
itemType: 'File',
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a known file without extension (README, LICENSE, etc.)
|
||||
const knownFiles = [
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
];
|
||||
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||
results.push({
|
||||
extra: fullPath,
|
||||
name: item,
|
||||
itemType: 'File',
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, try to determine if it's a directory
|
||||
try {
|
||||
await window.electron.listFiles(fullPath);
|
||||
|
||||
results.push({
|
||||
name: item,
|
||||
extra: fullPath,
|
||||
itemType: 'Directory',
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
|
||||
// Recursively scan directories more aggressively
|
||||
if (depth < 4 || priorityDirs.includes(item)) {
|
||||
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
|
||||
results.push(...subFiles);
|
||||
}
|
||||
} catch {
|
||||
// If we can't list it and it doesn't have a known extension, skip it
|
||||
// This could be a file with an unknown extension or a permission issue
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scanFilesFromRoot = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let startPath = currentWorkingDir;
|
||||
|
||||
if (!startPath) {
|
||||
if (window.electron.platform === 'win32') {
|
||||
startPath = 'C:\\Users';
|
||||
} else if (window.electron.platform === 'linux') {
|
||||
startPath = '/home';
|
||||
} else {
|
||||
startPath = '/Users'; // Default to macOS
|
||||
}
|
||||
}
|
||||
|
||||
const scannedFiles = await scanDirectoryFromRoot(startPath);
|
||||
setItems(scannedFiles);
|
||||
} catch (error) {
|
||||
console.error('Error scanning items from root:', error);
|
||||
setItems([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scanDirectoryFromRoot, currentWorkingDir]);
|
||||
|
||||
const compareByType = (a: DisplayItemWithMatch, b: DisplayItemWithMatch) => {
|
||||
const orderA = typeOrder[a.itemType] ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = typeOrder[b.itemType] ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
};
|
||||
|
||||
const displayItems = useMemo((): DisplayItemWithMatch[] => {
|
||||
if (!query.trim()) {
|
||||
return items
|
||||
.map((file) => ({
|
||||
...file,
|
||||
matchScore: 0,
|
||||
matches: [],
|
||||
matchedText: file.name,
|
||||
depth: currentWorkingDir
|
||||
? file.extra.replace(currentWorkingDir, '').split('/').length - 1
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.depth !== b.depth) return a.depth - b.depth;
|
||||
const typeComparison = compareByType(a, b);
|
||||
return typeComparison || a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
return items
|
||||
.map((file) => {
|
||||
const matches = [
|
||||
{ match: fuzzyMatch(query, file.name), text: file.name },
|
||||
{ match: fuzzyMatch(query, file.relativePath), text: file.relativePath },
|
||||
{ match: fuzzyMatch(query, file.extra), text: file.extra },
|
||||
];
|
||||
|
||||
const { match: bestMatch, text: matchedText } = matches.reduce((best, current) =>
|
||||
current.match.score > best.match.score ? current : best
|
||||
);
|
||||
|
||||
let finalScore = bestMatch.score;
|
||||
if (finalScore > 0 && currentWorkingDir) {
|
||||
const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1;
|
||||
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
matchScore: finalScore,
|
||||
matches: bestMatch.matches,
|
||||
matchedText,
|
||||
};
|
||||
})
|
||||
.filter((file) => file.matchScore > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.depth !== b.depth) return a.depth - b.depth;
|
||||
// Sort by score first, then prefer items over directories, then alphabetically
|
||||
const scoreDiff = b.matchScore - a.matchScore;
|
||||
if (Math.abs(scoreDiff) >= 1) return scoreDiff;
|
||||
const typeComparison = compareByType(a, b);
|
||||
return typeComparison || a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}, [items, query, currentWorkingDir]);
|
||||
|
||||
const results = files
|
||||
.map((file) => {
|
||||
const matches = [
|
||||
{ match: fuzzyMatch(query, file.name), text: file.name },
|
||||
{ match: fuzzyMatch(query, file.relativePath), text: file.relativePath },
|
||||
{ match: fuzzyMatch(query, file.path), text: file.path },
|
||||
];
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getDisplayFiles: () => displayItems,
|
||||
selectFile: (index: number) => {
|
||||
if (displayItems[index]) {
|
||||
onSelect(displayItems[index].extra);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[displayItems, onSelect, onClose]
|
||||
);
|
||||
|
||||
const { match: bestMatch, text: matchedText } = matches.reduce((best, current) =>
|
||||
current.match.score > best.match.score ? current : best
|
||||
);
|
||||
|
||||
let finalScore = bestMatch.score;
|
||||
if (finalScore > 0 && currentWorkingDir) {
|
||||
const depth = file.path.replace(currentWorkingDir, '').split('/').length - 1;
|
||||
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isSlashCommand) {
|
||||
const response = await getSlashCommands({ throwOnError: true });
|
||||
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
|
||||
name: cmd.command,
|
||||
extra: cmd.help,
|
||||
itemType: cmd.command_type,
|
||||
relativePath: cmd.command,
|
||||
}));
|
||||
setItems(commandItems);
|
||||
} else {
|
||||
await scanFilesFromRoot();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...file,
|
||||
matchScore: finalScore,
|
||||
matches: bestMatch.matches,
|
||||
matchedText,
|
||||
};
|
||||
})
|
||||
.filter((file) => file.matchScore > 0)
|
||||
.sort((a, b) => {
|
||||
// Sort by score first, then prefer files over directories, then alphabetically
|
||||
const scoreDiff = b.matchScore - a.matchScore;
|
||||
if (Math.abs(scoreDiff) >= 1) return scoreDiff;
|
||||
const typeComparison = compareByType(a, b);
|
||||
return typeComparison || a.name.localeCompare(b.name);
|
||||
});
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, isSlashCommand, scanFilesFromRoot]);
|
||||
|
||||
return results;
|
||||
}, [files, query, currentWorkingDir]);
|
||||
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getDisplayFiles: () => displayFiles,
|
||||
selectFile: (index: number) => {
|
||||
if (displayFiles[index]) {
|
||||
onSelect(displayFiles[index].path);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[displayFiles, onSelect, onClose]
|
||||
);
|
||||
};
|
||||
|
||||
// Scan files when component opens
|
||||
useEffect(() => {
|
||||
if (isOpen && files.length === 0) {
|
||||
scanFilesFromRoot();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, files.length]); // scanFilesFromRoot intentionally omitted to avoid circular dependency
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
// Handle clicks outside the popover
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current && selectedIndex >= 0 && selectedIndex < displayItems.length) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, displayItems.length]);
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
if (index >= 0 && index < displayItems.length) {
|
||||
onSelectedIndexChange(index);
|
||||
const displayItem = displayItems[index];
|
||||
onSelect(
|
||||
['Builtin', 'Recipe'].includes(displayItem.itemType)
|
||||
? '/' + displayItem.name
|
||||
: displayItem.extra
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
if (!isOpen) return null;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const scanDirectoryFromRoot = useCallback(
|
||||
async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
||||
// Increase depth limit for better file discovery
|
||||
if (depth > 5) return [];
|
||||
|
||||
try {
|
||||
const items = await window.electron.listFiles(dirPath);
|
||||
const results: FileItem[] = [];
|
||||
|
||||
// Common directories to prioritize or skip
|
||||
const priorityDirs = [
|
||||
'Desktop',
|
||||
'Documents',
|
||||
'Downloads',
|
||||
'Projects',
|
||||
'Development',
|
||||
'Code',
|
||||
'src',
|
||||
'components',
|
||||
'icons',
|
||||
];
|
||||
const skipDirs = [
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'.cache',
|
||||
'.npm',
|
||||
'.yarn',
|
||||
'Library',
|
||||
'System',
|
||||
'Applications',
|
||||
'.Trash',
|
||||
];
|
||||
|
||||
// Don't skip as many directories at deeper levels to find more files
|
||||
const skipDirsAtDepth =
|
||||
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||
|
||||
// Sort items to prioritize certain directories
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
const aPriority = priorityDirs.includes(a);
|
||||
const bPriority = priorityDirs.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Increase item limit per directory for better coverage
|
||||
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
|
||||
|
||||
for (const item of sortedItems.slice(0, itemLimit)) {
|
||||
const fullPath = `${dirPath}/${item}`;
|
||||
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
||||
|
||||
// Skip hidden files and common ignore patterns
|
||||
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First, check if this looks like a file based on extension
|
||||
const hasExtension = item.includes('.');
|
||||
const ext = item.split('.').pop()?.toLowerCase();
|
||||
const commonExtensions = [
|
||||
// Code files
|
||||
'txt',
|
||||
'md',
|
||||
'js',
|
||||
'ts',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'py',
|
||||
'java',
|
||||
'cpp',
|
||||
'c',
|
||||
'h',
|
||||
'css',
|
||||
'html',
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'php',
|
||||
'sql',
|
||||
'r',
|
||||
'scala',
|
||||
'swift',
|
||||
'kt',
|
||||
'dart',
|
||||
'vue',
|
||||
'svelte',
|
||||
'astro',
|
||||
'scss',
|
||||
'less',
|
||||
// Documentation
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
// Config files
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
// Images and assets
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'ico',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
// Vector and design files
|
||||
'ai',
|
||||
'eps',
|
||||
'sketch',
|
||||
'fig',
|
||||
'xd',
|
||||
'psd',
|
||||
// Other common files
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
];
|
||||
|
||||
// If it has a known file extension, treat it as a file
|
||||
if (hasExtension && ext && commonExtensions.includes(ext)) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a known file without extension (README, LICENSE, etc.)
|
||||
const knownFiles = [
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
];
|
||||
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, try to determine if it's a directory
|
||||
try {
|
||||
await window.electron.listFiles(fullPath);
|
||||
|
||||
// It's a directory
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: true,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
|
||||
// Recursively scan directories more aggressively
|
||||
if (depth < 4 || priorityDirs.includes(item)) {
|
||||
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
|
||||
results.push(...subFiles);
|
||||
}
|
||||
} catch {
|
||||
// If we can't list it and it doesn't have a known extension, skip it
|
||||
// This could be a file with an unknown extension or a permission issue
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scanFilesFromRoot = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let startPath = currentWorkingDir;
|
||||
|
||||
if (!startPath) {
|
||||
if (window.electron.platform === 'win32') {
|
||||
startPath = 'C:\\Users';
|
||||
} else if (window.electron.platform === 'linux') {
|
||||
startPath = '/home';
|
||||
} else {
|
||||
startPath = '/Users'; // Default to macOS
|
||||
}
|
||||
}
|
||||
|
||||
const scannedFiles = await scanDirectoryFromRoot(startPath);
|
||||
setFiles(scannedFiles);
|
||||
} catch (error) {
|
||||
console.error('Error scanning files from root:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scanDirectoryFromRoot, currentWorkingDir]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current && selectedIndex >= 0 && selectedIndex < displayFiles.length) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, displayFiles.length]);
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
if (index >= 0 && index < displayFiles.length) {
|
||||
onSelectedIndexChange(index);
|
||||
onSelect(displayFiles[index].path);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y - 10, // Position above the chat input
|
||||
transform: 'translateY(-100%)', // Move it fully above
|
||||
}}
|
||||
>
|
||||
<div className="p-3 flex flex-col max-h-80">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-textSubtle"></div>
|
||||
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{displayFiles.length > 0 && (
|
||||
<div className="text-xs text-textSubtle mb-2 px-1">
|
||||
{displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="space-y-1 overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-borderStandard scrollbar-track-transparent"
|
||||
style={{ maxHeight: '280px' }}
|
||||
>
|
||||
{displayFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => handleItemClick(index)}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-bgProminent text-textProminentInverse'
|
||||
: 'hover:bg-bgSubtle'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 text-textSubtle">
|
||||
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate text-textStandard">{file.name}</div>
|
||||
<div className="text-xs text-textSubtle truncate">{file.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && displayFiles.length === 0 && query && (
|
||||
<div className="p-4 text-center text-textSubtle text-sm">
|
||||
No files found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && displayFiles.length === 0 && !query && (
|
||||
<div className="p-4 text-center text-textSubtle text-sm">
|
||||
Start typing to search for files
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y - 10, // Position above the chat input
|
||||
transform: 'translateY(-100%)', // Move it fully above
|
||||
}}
|
||||
>
|
||||
<div className="p-3 flex flex-col max-h-80">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-textSubtle"></div>
|
||||
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{displayItems.length > 0 && (
|
||||
<div className="text-xs text-textSubtle mb-2 px-1">
|
||||
{displayItems.length} item{displayItems.length !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="space-y-1 overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-borderStandard scrollbar-track-transparent"
|
||||
style={{ maxHeight: '280px' }}
|
||||
>
|
||||
{displayItems.map((item, index) => (
|
||||
<div
|
||||
key={item.extra}
|
||||
onClick={() => handleItemClick(index)}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-bgProminent text-textProminentInverse'
|
||||
: 'hover:bg-bgSubtle'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 text-textSubtle">
|
||||
<ItemIcon item={item} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate text-textStandard">{item.name}</div>
|
||||
<div className="text-xs text-textSubtle truncate">{item.extra}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && displayItems.length === 0 && query && (
|
||||
<div className="p-4 text-center text-textSubtle text-sm">
|
||||
No items found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MentionPopover.displayName = 'MentionPopover';
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ import { useForm } from '@tanstack/react-form';
|
||||
import { Recipe, generateDeepLink, Parameter } from '../../recipe';
|
||||
import { Geese } from '../icons/Geese';
|
||||
import Copy from '../icons/Copy';
|
||||
import { Check, Save, Calendar, X, Play } from 'lucide-react';
|
||||
import { Check, Save, X, Play } from 'lucide-react';
|
||||
import { ExtensionConfig } from '../ConfigContext';
|
||||
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
|
||||
import { Button } from '../ui/button';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
|
||||
import { RecipeFormFields } from './shared/RecipeFormFields';
|
||||
import { RecipeFormData } from './shared/recipeFormSchema';
|
||||
@@ -29,7 +27,6 @@ export default function CreateEditRecipeModal({
|
||||
isCreateMode = false,
|
||||
recipeId,
|
||||
}: CreateEditRecipeModalProps) {
|
||||
const setView = useNavigation();
|
||||
const getInitialValues = React.useCallback((): RecipeFormData => {
|
||||
if (recipe) {
|
||||
return {
|
||||
@@ -81,7 +78,6 @@ export default function CreateEditRecipeModal({
|
||||
});
|
||||
}, [form]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize selected extensions for the recipe
|
||||
@@ -415,16 +411,6 @@ export default function CreateEditRecipeModal({
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
disabled={!requiredFieldsAreFilled()}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Create Schedule
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveRecipeClick}
|
||||
disabled={!requiredFieldsAreFilled() || isSaving}
|
||||
@@ -448,17 +434,6 @@ export default function CreateEditRecipeModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduleFromRecipeModal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={() => setIsScheduleModalOpen(false)}
|
||||
recipe={getCurrentRecipe()}
|
||||
onCreateSchedule={(deepLink) => {
|
||||
// Navigate to schedules view with the deep link in state
|
||||
setView('schedules', { pendingScheduleDeepLink: deepLink });
|
||||
setIsScheduleModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management';
|
||||
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
Link,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -8,46 +18,58 @@ import { Skeleton } from '../ui/skeleton';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { toastSuccess } from '../../toasts';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { deleteRecipe, RecipeManifestResponse, startAgent } from '../../api';
|
||||
import {
|
||||
deleteRecipe,
|
||||
RecipeManifest,
|
||||
startAgent,
|
||||
scheduleRecipe,
|
||||
setRecipeSlashCommand,
|
||||
} from '../../api';
|
||||
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
|
||||
import CreateEditRecipeModal from './CreateEditRecipeModal';
|
||||
import { generateDeepLink, Recipe } from '../../recipe';
|
||||
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import { CronPicker } from '../schedule/CronPicker';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
export default function RecipesView() {
|
||||
const setView = useNavigation();
|
||||
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
|
||||
const [savedRecipes, setSavedRecipes] = useState<RecipeManifest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifest | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
// Form dialog states
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [selectedRecipeForSchedule, setSelectedRecipeForSchedule] = useState<Recipe | null>(null);
|
||||
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
const [scheduleRecipeManifest, setScheduleRecipeManifest] = useState<RecipeManifest | null>(null);
|
||||
const [scheduleCron, setScheduleCron] = useState<string>('');
|
||||
|
||||
const [showSlashCommandDialog, setShowSlashCommandDialog] = useState(false);
|
||||
const [slashCommandRecipeManifest, setSlashCommandRecipeManifest] =
|
||||
useState<RecipeManifest | null>(null);
|
||||
const [slashCommand, setSlashCommand] = useState<string>('');
|
||||
const [scheduleValid, setScheduleIsValid] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedRecipes();
|
||||
}, []);
|
||||
|
||||
// Handle Esc key for editor modal
|
||||
useEscapeKey(showEditor, () => setShowEditor(false));
|
||||
|
||||
// Minimum loading time to prevent skeleton flash
|
||||
useEffect(() => {
|
||||
if (!loading && showSkeleton) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
// Add a small delay before showing content for fade-in effect
|
||||
setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 50);
|
||||
}, 300); // Show skeleton for at least 300ms
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -91,25 +113,15 @@ export default function RecipesView() {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// onLoadRecipe is not working for loading recipes. It looks correct
|
||||
// but the instructions are not flowing through to the server.
|
||||
// Needs a fix but commenting out to get prod back up and running.
|
||||
//
|
||||
// if (onLoadRecipe) {
|
||||
// // Use the callback to navigate within the same window
|
||||
// onLoadRecipe(savedRecipe.recipe);
|
||||
// } else {
|
||||
// Fallback to creating a new window (for backwards compatibility)
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipe, // recipe config
|
||||
undefined, // view type,
|
||||
recipeId // recipe id
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
recipe,
|
||||
undefined,
|
||||
recipeId
|
||||
);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
||||
@@ -117,8 +129,7 @@ export default function RecipesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => {
|
||||
// TODO: Use Electron's dialog API for confirmation
|
||||
const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => {
|
||||
const result = await window.electron.showMessageBox({
|
||||
type: 'warning',
|
||||
buttons: ['Cancel', 'Delete'],
|
||||
@@ -145,7 +156,7 @@ export default function RecipesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditRecipe = async (recipeManifest: RecipeManifestResponse) => {
|
||||
const handleEditRecipe = async (recipeManifest: RecipeManifest) => {
|
||||
setSelectedRecipe(recipeManifest);
|
||||
setShowEditor(true);
|
||||
};
|
||||
@@ -153,13 +164,12 @@ export default function RecipesView() {
|
||||
const handleEditorClose = (wasSaved?: boolean) => {
|
||||
setShowEditor(false);
|
||||
setSelectedRecipe(null);
|
||||
// Only reload recipes if a recipe was actually saved/updated
|
||||
if (wasSaved) {
|
||||
loadSavedRecipes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDeeplink = async (recipeManifest: RecipeManifestResponse) => {
|
||||
const handleCopyDeeplink = async (recipeManifest: RecipeManifest) => {
|
||||
try {
|
||||
const deeplink = await generateDeepLink(recipeManifest.recipe);
|
||||
await navigator.clipboard.writeText(deeplink);
|
||||
@@ -176,25 +186,132 @@ export default function RecipesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleRecipe = (recipe: Recipe) => {
|
||||
setSelectedRecipeForSchedule(recipe);
|
||||
setShowScheduleModal(true);
|
||||
const handleOpenScheduleDialog = (recipeManifest: RecipeManifest) => {
|
||||
setScheduleRecipeManifest(recipeManifest);
|
||||
setScheduleCron(recipeManifest.schedule_cron || '0 0 14 * * *');
|
||||
setShowScheduleDialog(true);
|
||||
};
|
||||
|
||||
const handleCreateScheduleFromRecipe = async (deepLink: string) => {
|
||||
// Navigate to schedules view with the deep link in state
|
||||
setView('schedules', { pendingScheduleDeepLink: deepLink });
|
||||
const handleSaveSchedule = async () => {
|
||||
if (!scheduleRecipeManifest) return;
|
||||
|
||||
setShowScheduleModal(false);
|
||||
setSelectedRecipeForSchedule(null);
|
||||
try {
|
||||
await scheduleRecipe({
|
||||
body: {
|
||||
id: scheduleRecipeManifest.id,
|
||||
cron_schedule: scheduleCron,
|
||||
},
|
||||
});
|
||||
|
||||
toastSuccess({
|
||||
title: 'Schedule saved',
|
||||
msg: `Recipe will run ${getReadableCron(scheduleCron)}`,
|
||||
});
|
||||
|
||||
setShowScheduleDialog(false);
|
||||
setScheduleRecipeManifest(null);
|
||||
await loadSavedRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to save schedule:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to save schedule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSchedule = async () => {
|
||||
if (!scheduleRecipeManifest) return;
|
||||
|
||||
try {
|
||||
await scheduleRecipe({
|
||||
body: {
|
||||
id: scheduleRecipeManifest.id,
|
||||
cron_schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
toastSuccess({
|
||||
title: 'Schedule removed',
|
||||
msg: 'Recipe will no longer run automatically',
|
||||
});
|
||||
|
||||
setShowScheduleDialog(false);
|
||||
setScheduleRecipeManifest(null);
|
||||
await loadSavedRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove schedule:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to remove schedule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSlashCommandDialog = (recipeManifest: RecipeManifest) => {
|
||||
setSlashCommandRecipeManifest(recipeManifest);
|
||||
setSlashCommand(recipeManifest.slash_command || '');
|
||||
setShowSlashCommandDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveSlashCommand = async () => {
|
||||
if (!slashCommandRecipeManifest) return;
|
||||
|
||||
try {
|
||||
await setRecipeSlashCommand({
|
||||
body: {
|
||||
id: slashCommandRecipeManifest.id,
|
||||
slash_command: slashCommand || null,
|
||||
},
|
||||
});
|
||||
|
||||
toastSuccess({
|
||||
title: 'Slash command saved',
|
||||
msg: slashCommand ? `Use /${slashCommand} to run this recipe` : 'Slash command removed',
|
||||
});
|
||||
|
||||
setShowSlashCommandDialog(false);
|
||||
setSlashCommandRecipeManifest(null);
|
||||
await loadSavedRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to save slash command:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to save slash command');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSlashCommand = async () => {
|
||||
if (!slashCommandRecipeManifest) return;
|
||||
|
||||
try {
|
||||
await setRecipeSlashCommand({
|
||||
body: {
|
||||
id: slashCommandRecipeManifest.id,
|
||||
slash_command: null,
|
||||
},
|
||||
});
|
||||
|
||||
toastSuccess({
|
||||
title: 'Slash command removed',
|
||||
msg: 'Recipe slash command has been removed',
|
||||
});
|
||||
|
||||
setShowSlashCommandDialog(false);
|
||||
setSlashCommandRecipeManifest(null);
|
||||
await loadSavedRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove slash command:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to remove slash command');
|
||||
}
|
||||
};
|
||||
|
||||
const getReadableCron = (cron: string): string => {
|
||||
try {
|
||||
const cronWithoutSeconds = cron.split(' ').slice(1).join(' ');
|
||||
return cronstrue.toString(cronWithoutSeconds).toLowerCase();
|
||||
} catch {
|
||||
return cron;
|
||||
}
|
||||
};
|
||||
|
||||
// Render a recipe item
|
||||
const RecipeItem = ({
|
||||
recipeManifestResponse,
|
||||
recipeManifestResponse: { recipe, lastModified },
|
||||
recipeManifestResponse: { recipe, last_modified: lastModified, schedule_cron, slash_command },
|
||||
}: {
|
||||
recipeManifestResponse: RecipeManifestResponse;
|
||||
recipeManifestResponse: RecipeManifest;
|
||||
}) => (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted transition-all duration-150">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
@@ -203,12 +320,42 @@ export default function RecipesView() {
|
||||
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{convertToLocaleDateString(lastModified)}
|
||||
<div className="flex flex-col gap-1 text-xs text-text-muted">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{convertToLocaleDateString(lastModified)}
|
||||
</div>
|
||||
{(schedule_cron || slash_command) && (
|
||||
<div className="flex items-center gap-3">
|
||||
{schedule_cron && (
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Runs {getReadableCron(schedule_cron)}
|
||||
</div>
|
||||
)}
|
||||
{slash_command && (
|
||||
<div className="flex items-center text-purple-600 dark:text-purple-400">
|
||||
/{slash_command}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenSlashCommandDialog(recipeManifestResponse);
|
||||
}}
|
||||
variant={slash_command ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title={slash_command ? 'Edit slash command' : 'Add slash command'}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
@@ -248,14 +395,14 @@ export default function RecipesView() {
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScheduleRecipe(recipe);
|
||||
handleOpenScheduleDialog(recipeManifestResponse);
|
||||
}}
|
||||
variant="outline"
|
||||
variant={schedule_cron ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Create schedule"
|
||||
title={schedule_cron ? 'Edit schedule' : 'Add schedule'}
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<Clock className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
@@ -274,7 +421,6 @@ export default function RecipesView() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Render skeleton loader for recipe items
|
||||
const RecipeSkeleton = () => (
|
||||
<Card className="p-2 mb-2 bg-background-default">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
@@ -334,7 +480,7 @@ export default function RecipesView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => (
|
||||
{savedRecipes.map((recipeManifestResponse: RecipeManifest) => (
|
||||
<RecipeItem
|
||||
key={recipeManifestResponse.id}
|
||||
recipeManifestResponse={recipeManifestResponse}
|
||||
@@ -412,16 +558,91 @@ export default function RecipesView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScheduleModal && selectedRecipeForSchedule && (
|
||||
<ScheduleFromRecipeModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => {
|
||||
setShowScheduleModal(false);
|
||||
setSelectedRecipeForSchedule(null);
|
||||
}}
|
||||
recipe={selectedRecipeForSchedule}
|
||||
onCreateSchedule={handleCreateScheduleFromRecipe}
|
||||
/>
|
||||
{showScheduleDialog && scheduleRecipeManifest && (
|
||||
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{scheduleRecipeManifest.schedule_cron ? 'Edit' : 'Add'} Schedule
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<CronPicker
|
||||
schedule={
|
||||
scheduleRecipeManifest.schedule_cron
|
||||
? {
|
||||
id: scheduleRecipeManifest.id,
|
||||
source: '',
|
||||
cron: scheduleRecipeManifest.schedule_cron,
|
||||
last_run: null,
|
||||
currently_running: false,
|
||||
paused: false,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={setScheduleCron}
|
||||
isValid={setScheduleIsValid}
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
{scheduleRecipeManifest.schedule_cron && (
|
||||
<Button variant="outline" onClick={handleRemoveSchedule}>
|
||||
Remove Schedule
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setShowScheduleDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveSchedule} disabled={!scheduleValid}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{showSlashCommandDialog && slashCommandRecipeManifest && (
|
||||
<Dialog open={showSlashCommandDialog} onOpenChange={setShowSlashCommandDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Slash Command</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Set a slash command to quickly run this recipe from any chat
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<input
|
||||
type="text"
|
||||
value={slashCommand}
|
||||
onChange={(e) => setSlashCommand(e.target.value)}
|
||||
placeholder="command-name"
|
||||
className="flex-1 px-3 py-2 border rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
{slashCommand && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Use /{slashCommand} in any chat to run this recipe
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
{slashCommandRecipeManifest.slash_command && (
|
||||
<Button variant="outline" onClick={handleRemoveSlashCommand}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setShowSlashCommandDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveSlashCommand}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
|
||||
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifest } from '../api';
|
||||
|
||||
export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Promise<string> => {
|
||||
try {
|
||||
@@ -19,10 +19,10 @@ export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Prom
|
||||
}
|
||||
};
|
||||
|
||||
export const listSavedRecipes = async (): Promise<RecipeManifestResponse[]> => {
|
||||
export const listSavedRecipes = async (): Promise<RecipeManifest[]> => {
|
||||
try {
|
||||
const listRecipeResponse = await listRecipes();
|
||||
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
|
||||
return listRecipeResponse?.data?.manifests ?? [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to list saved recipes:', error);
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user