mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: show installed skills in UI (#7910)
Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -135,6 +135,7 @@ pub enum ConfigValueResponse {
|
||||
pub enum CommandType {
|
||||
Builtin,
|
||||
Recipe,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -387,14 +388,23 @@ pub async fn get_provider_models(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::IntoParams)]
|
||||
pub struct SlashCommandsQuery {
|
||||
/// Optional working directory to discover local skills from
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/config/slash_commands",
|
||||
params(SlashCommandsQuery),
|
||||
responses(
|
||||
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
|
||||
pub async fn get_slash_commands(
|
||||
axum::extract::Query(query): axum::extract::Query<SlashCommandsQuery>,
|
||||
) -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
|
||||
let mut commands: Vec<_> = slash_commands::list_commands()
|
||||
.iter()
|
||||
.map(|command| SlashCommand {
|
||||
@@ -412,6 +422,23 @@ pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorRe
|
||||
});
|
||||
}
|
||||
|
||||
let working_dir = query.working_dir.map(std::path::PathBuf::from);
|
||||
for source in
|
||||
goose::agents::platform_extensions::summon::list_installed_sources(working_dir.as_deref())
|
||||
{
|
||||
if matches!(
|
||||
source.kind,
|
||||
goose::agents::platform_extensions::summon::SourceKind::Skill
|
||||
| goose::agents::platform_extensions::summon::SourceKind::BuiltinSkill
|
||||
) {
|
||||
commands.push(SlashCommand {
|
||||
command: source.name,
|
||||
help: source.description,
|
||||
command_type: CommandType::Skill,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(SlashCommandsResponse { commands }))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ static COMMANDS: &[CommandDef] = &[
|
||||
name: "clear",
|
||||
description: "Clear the conversation history",
|
||||
},
|
||||
CommandDef {
|
||||
name: "skills",
|
||||
description: "List installed skills and other available sources",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn list_commands() -> &'static [CommandDef] {
|
||||
@@ -72,6 +76,7 @@ impl Agent {
|
||||
"prompt" => self.handle_prompt_command(¶ms, session_id).await,
|
||||
"compact" => self.handle_compact_command(session_id).await,
|
||||
"clear" => self.handle_clear_command(session_id).await,
|
||||
"skills" => self.handle_skills_command(session_id).await,
|
||||
_ => {
|
||||
self.handle_recipe_command(command, params_str, session_id)
|
||||
.await
|
||||
@@ -129,6 +134,49 @@ impl Agent {
|
||||
)))
|
||||
}
|
||||
|
||||
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
|
||||
use super::platform_extensions::summon::{list_installed_sources, SourceKind};
|
||||
|
||||
let working_dir = self
|
||||
.config
|
||||
.session_manager
|
||||
.get_session(session_id, false)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.working_dir);
|
||||
let sources = list_installed_sources(working_dir.as_deref());
|
||||
let skills: Vec<_> = sources
|
||||
.iter()
|
||||
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
if skills.is_empty() {
|
||||
output.push_str("No skills installed.\n\n");
|
||||
output.push_str("Skills are loaded from SKILL.md files in:\n");
|
||||
output.push_str(" - ~/.claude/skills/\n");
|
||||
output.push_str(" - ~/.config/agents/skills/\n");
|
||||
output.push_str(" - .goose/skills/ (in current directory)\n");
|
||||
output.push_str(" - .claude/skills/ (in current directory)\n");
|
||||
output.push_str(" - .agents/skills/ (in current directory)\n");
|
||||
} else {
|
||||
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
|
||||
for skill in &skills {
|
||||
let kind_label = if skill.kind == SourceKind::BuiltinSkill {
|
||||
" *(builtin)*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
output.push_str(&format!(
|
||||
"- **{}**{}: {}\n",
|
||||
skill.name, kind_label, skill.description
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(Message::assistant().with_text(output)))
|
||||
}
|
||||
|
||||
async fn handle_prompts_command(
|
||||
&self,
|
||||
params: &[&str],
|
||||
|
||||
@@ -390,6 +390,16 @@ fn scan_agents_from_dir(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all discovered sources (skills, recipes, agents) from the given working directory.
|
||||
/// If no directory is provided, falls back to `std::env::current_dir()`.
|
||||
/// This is useful for listing what's available without needing a full SummonClient instance.
|
||||
pub fn list_installed_sources(working_dir: Option<&Path>) -> Vec<Source> {
|
||||
let dir = working_dir
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
discover_filesystem_sources(&dir)
|
||||
}
|
||||
|
||||
fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
|
||||
let mut sources: Vec<Source> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
@@ -10,6 +10,8 @@ const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
|
||||
const POLL_TIMEOUT_SECS: u64 = 30;
|
||||
const MAX_MESSAGE_LENGTH: usize = 4096;
|
||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// Maximum voice file size we'll attempt to download (20 MB, Telegram's bot API limit).
|
||||
const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024;
|
||||
|
||||
pub struct TelegramGateway {
|
||||
bot_token: String,
|
||||
@@ -28,6 +30,45 @@ struct TelegramMessage {
|
||||
from: Option<TelegramUser>,
|
||||
chat: TelegramChat,
|
||||
text: Option<String>,
|
||||
voice: Option<TelegramVoice>,
|
||||
audio: Option<TelegramAudio>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramVoice {
|
||||
file_id: String,
|
||||
#[allow(dead_code)]
|
||||
duration: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
mime_type: Option<String>,
|
||||
file_size: Option<i64>,
|
||||
}
|
||||
|
||||
/// Audio files sent as documents (not inline voice notes).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramAudio {
|
||||
file_id: String,
|
||||
#[allow(dead_code)]
|
||||
duration: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
mime_type: Option<String>,
|
||||
file_size: Option<i64>,
|
||||
}
|
||||
|
||||
/// Metadata extracted from a Telegram voice note or audio attachment.
|
||||
struct VoiceInfo<'a> {
|
||||
file_id: &'a str,
|
||||
file_size: Option<i64>,
|
||||
duration: Option<i32>,
|
||||
mime_type: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Response from the Telegram `getFile` API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramFile {
|
||||
#[allow(dead_code)]
|
||||
file_id: String,
|
||||
file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -146,6 +187,137 @@ impl TelegramGateway {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a file from Telegram by its `file_id`.
|
||||
///
|
||||
/// This is a two-step process:
|
||||
/// 1. Call `getFile` to obtain the server-side `file_path`.
|
||||
/// 2. Fetch the raw bytes from `https://api.telegram.org/file/bot<TOKEN>/<file_path>`.
|
||||
async fn download_file(&self, file_id: &str) -> anyhow::Result<Vec<u8>> {
|
||||
// Step 1 – resolve file_id → file_path
|
||||
let resp: TelegramResponse<TelegramFile> = self
|
||||
.client
|
||||
.post(self.api_url("getFile"))
|
||||
.json(&serde_json::json!({ "file_id": file_id }))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let tg_file = resp.result.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Telegram getFile error: {}",
|
||||
resp.description.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
|
||||
let file_path = tg_file
|
||||
.file_path
|
||||
.ok_or_else(|| anyhow::anyhow!("Telegram getFile returned no file_path"))?;
|
||||
|
||||
// Step 2 – download raw bytes
|
||||
let download_url = format!(
|
||||
"{}/file/bot{}/{}",
|
||||
TELEGRAM_API_BASE, self.bot_token, file_path
|
||||
);
|
||||
let bytes = self.client.get(&download_url).send().await?.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Save voice bytes to a temporary file and return the path.
|
||||
///
|
||||
/// Files are stored under `<tmp>/goose_voice/voice_<uuid>.<ext>` so Goose
|
||||
/// can access them via its shell tools. The extension is derived from the
|
||||
/// MIME type when available, falling back to `.ogg` for voice notes.
|
||||
///
|
||||
/// On Unix the directory is created with mode `0700` and files with `0600`
|
||||
/// so other local users cannot read private voice content.
|
||||
fn save_voice_file(
|
||||
bytes: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> anyhow::Result<std::path::PathBuf> {
|
||||
let dir = std::env::temp_dir().join("goose_voice");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
// Restrict directory permissions to owner-only on Unix.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
|
||||
let ext = mime_type
|
||||
.and_then(|m| m.rsplit('/').next())
|
||||
.map(|sub| {
|
||||
// Normalise common MIME sub-types to file extensions.
|
||||
match sub {
|
||||
"mpeg" => "mp3",
|
||||
"mp4" | "x-m4a" => "m4a",
|
||||
"ogg" => "ogg",
|
||||
"wav" | "x-wav" => "wav",
|
||||
other => other,
|
||||
}
|
||||
})
|
||||
.unwrap_or("ogg");
|
||||
|
||||
let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4());
|
||||
let path = dir.join(filename);
|
||||
std::fs::write(&path, bytes)?;
|
||||
|
||||
// Restrict file permissions to owner-only on Unix.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Build the text prompt that tells Goose about a voice message file.
|
||||
fn voice_prompt(
|
||||
path: &std::path::Path,
|
||||
duration: Option<i32>,
|
||||
mime_type: Option<&str>,
|
||||
) -> String {
|
||||
let duration_hint = duration
|
||||
.map(|d| format!(" (duration: {d}s)"))
|
||||
.unwrap_or_default();
|
||||
let format_hint = mime_type
|
||||
.map(|m| format!(" The file format is {m}."))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"The user sent a voice message{duration_hint}. \
|
||||
The audio file is saved at: {}{format_hint}\n\n\
|
||||
Please transcribe this audio file using available command-line tools \
|
||||
(e.g. whisper, ffmpeg, sox, or any STT utility you can find on this system) \
|
||||
and then respond to what the user said. \
|
||||
If no transcription tool is available, let the user know and ask them to type their message instead.",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract metadata from either a voice note or an audio attachment.
|
||||
/// Returns `None` when neither is present.
|
||||
fn voice_info(msg: &TelegramMessage) -> Option<VoiceInfo<'_>> {
|
||||
if let Some(ref v) = msg.voice {
|
||||
return Some(VoiceInfo {
|
||||
file_id: &v.file_id,
|
||||
file_size: v.file_size,
|
||||
duration: v.duration,
|
||||
mime_type: v.mime_type.as_deref(),
|
||||
});
|
||||
}
|
||||
if let Some(ref a) = msg.audio {
|
||||
return Some(VoiceInfo {
|
||||
file_id: &a.file_id,
|
||||
file_size: a.file_size,
|
||||
duration: a.duration,
|
||||
mime_type: a.mime_type.as_deref(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser {
|
||||
PlatformUser {
|
||||
platform: "telegram".to_string(),
|
||||
@@ -177,6 +349,21 @@ impl Gateway for TelegramGateway {
|
||||
|
||||
tracing::info!("Telegram gateway starting long-poll loop");
|
||||
|
||||
// Spawn a background task that periodically removes stale voice files
|
||||
// (older than 1 hour) so they don't accumulate on disk.
|
||||
let cleanup_cancel = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cleanup_cancel.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
cleanup_voice_files(std::time::Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
@@ -192,9 +379,46 @@ impl Gateway for TelegramGateway {
|
||||
let Some(tg_msg) = update.message else {
|
||||
continue;
|
||||
};
|
||||
let text = match tg_msg.text {
|
||||
Some(ref t) => t.clone(),
|
||||
None => continue,
|
||||
|
||||
// Determine the text to send to the handler.
|
||||
// Voice/audio messages are downloaded, saved to
|
||||
// disk, and converted into a prompt that asks
|
||||
// Goose to transcribe the file using CLI tools.
|
||||
let text = if let Some(voice) = Self::voice_info(&tg_msg) {
|
||||
// Reject files that exceed the Telegram bot
|
||||
// download limit.
|
||||
if voice.file_size.unwrap_or(0) > MAX_VOICE_FILE_SIZE {
|
||||
tracing::warn!(
|
||||
file_size = voice.file_size,
|
||||
"voice file exceeds size limit, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.download_file(voice.file_id).await {
|
||||
Ok(bytes) => match Self::save_voice_file(&bytes, voice.mime_type) {
|
||||
Ok(path) => Self::voice_prompt(&path, voice.duration, voice.mime_type),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"failed to save voice file"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"failed to download voice file from Telegram"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(ref t) = tg_msg.text {
|
||||
t.clone()
|
||||
} else {
|
||||
// Neither text nor voice — skip.
|
||||
continue;
|
||||
};
|
||||
|
||||
let user = Self::to_platform_user(&tg_msg);
|
||||
@@ -273,6 +497,29 @@ impl Gateway for TelegramGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove voice files from the temp directory that are older than `max_age`.
|
||||
fn cleanup_voice_files(max_age: std::time::Duration) {
|
||||
let dir = std::env::temp_dir().join("goose_voice");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
let cutoff = std::time::SystemTime::now() - max_age;
|
||||
let mut removed = 0u32;
|
||||
for entry in entries.flatten() {
|
||||
let dominated = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.is_some_and(|t| t < cutoff);
|
||||
if dominated && std::fs::remove_file(entry.path()).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
tracing::debug!(removed, "cleaned up stale voice files");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::string_slice)]
|
||||
fn split_message(text: &str, max_len: usize) -> Vec<String> {
|
||||
if text.len() <= max_len {
|
||||
@@ -384,6 +631,173 @@ mod tests {
|
||||
assert_eq!(chunks[1].chars().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_info_from_voice_message() {
|
||||
let msg = TelegramMessage {
|
||||
message_id: 1,
|
||||
from: None,
|
||||
chat: TelegramChat {
|
||||
id: 123,
|
||||
chat_type: "private".into(),
|
||||
},
|
||||
text: None,
|
||||
voice: Some(TelegramVoice {
|
||||
file_id: "voice_file_123".into(),
|
||||
duration: Some(5),
|
||||
mime_type: Some("audio/ogg".into()),
|
||||
file_size: Some(10000),
|
||||
}),
|
||||
audio: None,
|
||||
};
|
||||
let info = TelegramGateway::voice_info(&msg);
|
||||
assert!(info.is_some());
|
||||
let v = info.unwrap();
|
||||
assert_eq!(v.file_id, "voice_file_123");
|
||||
assert_eq!(v.file_size, Some(10000));
|
||||
assert_eq!(v.duration, Some(5));
|
||||
assert_eq!(v.mime_type, Some("audio/ogg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_info_from_audio_message() {
|
||||
let msg = TelegramMessage {
|
||||
message_id: 1,
|
||||
from: None,
|
||||
chat: TelegramChat {
|
||||
id: 123,
|
||||
chat_type: "private".into(),
|
||||
},
|
||||
text: None,
|
||||
voice: None,
|
||||
audio: Some(TelegramAudio {
|
||||
file_id: "audio_file_456".into(),
|
||||
duration: Some(120),
|
||||
mime_type: Some("audio/mpeg".into()),
|
||||
file_size: Some(500_000),
|
||||
}),
|
||||
};
|
||||
let info = TelegramGateway::voice_info(&msg);
|
||||
assert!(info.is_some());
|
||||
let v = info.unwrap();
|
||||
assert_eq!(v.file_id, "audio_file_456");
|
||||
assert_eq!(v.duration, Some(120));
|
||||
assert_eq!(v.mime_type, Some("audio/mpeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_info_none_for_text() {
|
||||
let msg = TelegramMessage {
|
||||
message_id: 1,
|
||||
from: None,
|
||||
chat: TelegramChat {
|
||||
id: 123,
|
||||
chat_type: "private".into(),
|
||||
},
|
||||
text: Some("hello".into()),
|
||||
voice: None,
|
||||
audio: None,
|
||||
};
|
||||
assert!(TelegramGateway::voice_info(&msg).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_prefers_voice_over_audio() {
|
||||
let msg = TelegramMessage {
|
||||
message_id: 1,
|
||||
from: None,
|
||||
chat: TelegramChat {
|
||||
id: 123,
|
||||
chat_type: "private".into(),
|
||||
},
|
||||
text: None,
|
||||
voice: Some(TelegramVoice {
|
||||
file_id: "voice_wins".into(),
|
||||
duration: Some(3),
|
||||
mime_type: None,
|
||||
file_size: None,
|
||||
}),
|
||||
audio: Some(TelegramAudio {
|
||||
file_id: "audio_loses".into(),
|
||||
duration: Some(60),
|
||||
mime_type: None,
|
||||
file_size: None,
|
||||
}),
|
||||
};
|
||||
let v = TelegramGateway::voice_info(&msg).unwrap();
|
||||
assert_eq!(v.file_id, "voice_wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_prompt_includes_path_and_duration() {
|
||||
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
|
||||
let prompt = TelegramGateway::voice_prompt(&path, Some(10), Some("audio/ogg"));
|
||||
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
|
||||
assert!(prompt.contains("(duration: 10s)"));
|
||||
assert!(prompt.contains("audio/ogg"));
|
||||
assert!(prompt.contains("transcribe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_prompt_without_duration() {
|
||||
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
|
||||
let prompt = TelegramGateway::voice_prompt(&path, None, None);
|
||||
assert!(!prompt.contains("duration"));
|
||||
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_prompt_with_mp3_mime() {
|
||||
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.mp3");
|
||||
let prompt = TelegramGateway::voice_prompt(&path, Some(60), Some("audio/mpeg"));
|
||||
assert!(prompt.contains("audio/mpeg"));
|
||||
assert!(!prompt.contains("OGG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_voice_file_creates_file_ogg() {
|
||||
let bytes = b"fake ogg data";
|
||||
let path = TelegramGateway::save_voice_file(bytes, Some("audio/ogg")).unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(path.to_str().unwrap().ends_with(".ogg"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_voice_file_creates_file_mp3() {
|
||||
let bytes = b"fake mp3 data";
|
||||
let path = TelegramGateway::save_voice_file(bytes, Some("audio/mpeg")).unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(path.to_str().unwrap().ends_with(".mp3"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_voice_file_defaults_to_ogg() {
|
||||
let bytes = b"unknown format";
|
||||
let path = TelegramGateway::save_voice_file(bytes, None).unwrap();
|
||||
assert!(path.to_str().unwrap().ends_with(".ogg"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_preserves_recent_files() {
|
||||
let dir = std::env::temp_dir().join("goose_voice");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let recent_file = dir.join("voice_cleanup_recent_test.ogg");
|
||||
std::fs::write(&recent_file, b"recent").unwrap();
|
||||
// With a 1-hour max_age, a just-created file should survive.
|
||||
cleanup_voice_files(std::time::Duration::from_secs(3600));
|
||||
assert!(recent_file.exists());
|
||||
let _ = std::fs::remove_file(&recent_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_handles_missing_dir() {
|
||||
// Should not panic even when the directory doesn't exist.
|
||||
cleanup_voice_files(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_preserves_content() {
|
||||
let text = format!(
|
||||
|
||||
+14
-1
@@ -1569,6 +1569,18 @@
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "get_slash_commands",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "working_dir",
|
||||
"in": "query",
|
||||
"description": "Optional working directory to discover local skills from",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slash commands retrieved successfully",
|
||||
@@ -4139,7 +4151,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Builtin",
|
||||
"Recipe"
|
||||
"Recipe",
|
||||
"Skill"
|
||||
]
|
||||
},
|
||||
"ConfigKey": {
|
||||
|
||||
@@ -80,7 +80,7 @@ export type CheckProviderRequest = {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export type CommandType = 'Builtin' | 'Recipe';
|
||||
export type CommandType = 'Builtin' | 'Recipe' | 'Skill';
|
||||
|
||||
/**
|
||||
* Configuration key metadata for provider setup
|
||||
@@ -2837,7 +2837,12 @@ export type SetConfigProviderData = {
|
||||
export type GetSlashCommandsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Optional working directory to discover local skills from
|
||||
*/
|
||||
working_dir?: string | null;
|
||||
};
|
||||
url: '/config/slash_commands';
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Zap,
|
||||
BookOpen,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { DisplayItem } from './MentionPopover';
|
||||
|
||||
@@ -33,6 +34,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => {
|
||||
return { Icon: Zap, color: '#3b82f6' }; // Blue
|
||||
case 'Recipe':
|
||||
return { Icon: BookOpen, color: '#10b981' }; // Green
|
||||
case 'Skill':
|
||||
return { Icon: Sparkles, color: '#8b5cf6' }; // Purple
|
||||
case 'Directory':
|
||||
return { Icon: Folder, color: '#f59e0b' }; // Amber
|
||||
default: {
|
||||
|
||||
@@ -17,7 +17,8 @@ const typeOrder: Record<DisplayItemType, number> = {
|
||||
Directory: 0,
|
||||
File: 1,
|
||||
Builtin: 2,
|
||||
Recipe: 3,
|
||||
Skill: 3,
|
||||
Recipe: 4,
|
||||
};
|
||||
|
||||
export interface DisplayItem {
|
||||
@@ -441,6 +442,16 @@ const MentionPopover = forwardRef<
|
||||
});
|
||||
}, [items, query, currentWorkingDir]);
|
||||
|
||||
const getSelectionText = (item: DisplayItem): string => {
|
||||
if (item.itemType === 'Skill') {
|
||||
return `Use the ${item.name} skill to `;
|
||||
}
|
||||
if (['Builtin', 'Recipe'].includes(item.itemType)) {
|
||||
return '/' + item.name;
|
||||
}
|
||||
return item.extra;
|
||||
};
|
||||
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -448,7 +459,7 @@ const MentionPopover = forwardRef<
|
||||
getDisplayFiles: () => displayItems,
|
||||
selectFile: (index: number) => {
|
||||
if (displayItems[index]) {
|
||||
onSelect(displayItems[index].extra);
|
||||
onSelect(getSelectionText(displayItems[index]));
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
@@ -459,7 +470,10 @@ const MentionPopover = forwardRef<
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isSlashCommand) {
|
||||
const response = await getSlashCommands({ throwOnError: true });
|
||||
const response = await getSlashCommands({
|
||||
query: { working_dir: currentWorkingDir },
|
||||
throwOnError: true,
|
||||
});
|
||||
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
|
||||
name: cmd.command,
|
||||
extra: cmd.help,
|
||||
@@ -475,7 +489,7 @@ const MentionPopover = forwardRef<
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, isSlashCommand, scanFilesFromRoot]);
|
||||
}, [isOpen, isSlashCommand, scanFilesFromRoot, currentWorkingDir]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -509,12 +523,7 @@ const MentionPopover = forwardRef<
|
||||
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
|
||||
);
|
||||
onSelect(getSelectionText(displayItems[index]));
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -551,7 +560,7 @@ const MentionPopover = forwardRef<
|
||||
>
|
||||
{displayItems.map((item, index) => (
|
||||
<div
|
||||
key={item.extra}
|
||||
key={`${item.itemType}-${item.name}`}
|
||||
onClick={() => handleItemClick(index)}
|
||||
data-selected={index === selectedIndex}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||
|
||||
Reference in New Issue
Block a user