mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-04-25 17:25:11 +02:00
commands ui
This commit is contained in:
@@ -6,7 +6,6 @@ use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use dashmap::DashMap;
|
||||
use openfang_channels::bridge::channel_command_specs;
|
||||
use openfang_kernel::triggers::{TriggerId, TriggerPattern};
|
||||
use openfang_kernel::workflow::{
|
||||
ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep,
|
||||
@@ -10746,40 +10745,82 @@ pub async fn pairing_notify(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /api/commands — List available chat commands (for dynamic slash menu).
|
||||
pub async fn list_commands(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let mut commands: Vec<serde_json::Value> = channel_command_specs()
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
/// GET /api/commands?surface=web|cli|channel|all — List slash commands from the
|
||||
/// unified command registry, filtered by surface.
|
||||
///
|
||||
/// Query params:
|
||||
/// - `surface`: one of `web` (default), `cli`, `channel`, `all`.
|
||||
///
|
||||
/// Returns:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "surface": "web",
|
||||
/// "commands": [
|
||||
/// {
|
||||
/// "name": "new",
|
||||
/// "aliases": ["reset"],
|
||||
/// "description": "Reset session (clear history)",
|
||||
/// "category": "session",
|
||||
/// "requires_agent": true
|
||||
/// },
|
||||
/// ...
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Unknown surface values return 400.
|
||||
pub async fn list_commands(Query(params): Query<CommandsQuery>) -> impl IntoResponse {
|
||||
use openfang_types::commands::{self, CommandCategory, Surfaces};
|
||||
|
||||
let surface_raw = params.surface.as_deref().unwrap_or("web");
|
||||
let surface = match surface_raw.to_ascii_lowercase().as_str() {
|
||||
"web" => Surfaces::WEB,
|
||||
"cli" => Surfaces::CLI,
|
||||
"channel" => Surfaces::CHANNEL,
|
||||
"all" => Surfaces::ALL,
|
||||
other => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": format!(
|
||||
"Unknown surface '{other}'. Valid: web, cli, channel, all."
|
||||
),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let category_slug = |c: CommandCategory| -> &'static str {
|
||||
match c {
|
||||
CommandCategory::General => "general",
|
||||
CommandCategory::Session => "session",
|
||||
CommandCategory::Model => "model",
|
||||
CommandCategory::Memory => "memory",
|
||||
CommandCategory::Control => "control",
|
||||
CommandCategory::Info => "info",
|
||||
CommandCategory::Automation => "automation",
|
||||
CommandCategory::Monitoring => "monitoring",
|
||||
}
|
||||
};
|
||||
|
||||
let commands: Vec<serde_json::Value> = commands::list_for_surface(surface)
|
||||
.map(|def| {
|
||||
serde_json::json!({
|
||||
"cmd": format!("/{}", spec.name),
|
||||
"desc": spec.desc,
|
||||
"source": "channel",
|
||||
"name": def.name,
|
||||
"aliases": def.aliases,
|
||||
"description": def.description,
|
||||
"category": category_slug(def.category),
|
||||
"requires_agent": def.requires_agent,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
commands.extend([
|
||||
serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}),
|
||||
serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}),
|
||||
serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}),
|
||||
serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}),
|
||||
serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}),
|
||||
]);
|
||||
|
||||
// Add skill-registered tool names as potential commands
|
||||
if let Ok(registry) = state.kernel.skill_registry.read() {
|
||||
for skill in registry.list() {
|
||||
let desc: String = skill.manifest.skill.description.chars().take(80).collect();
|
||||
commands.push(serde_json::json!({
|
||||
"cmd": format!("/{}", skill.manifest.skill.name),
|
||||
"desc": if desc.is_empty() { format!("Skill: {}", skill.manifest.skill.name) } else { desc },
|
||||
"source": "skill",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"commands": commands}))
|
||||
Json(serde_json::json!({
|
||||
"surface": surface_raw.to_ascii_lowercase(),
|
||||
"commands": commands,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// SECURITY: Validate webhook bearer token using constant-time comparison.
|
||||
|
||||
@@ -107,3 +107,11 @@ pub struct ClawHubInstallRequest {
|
||||
/// ClawHub skill slug (e.g., "github-helper").
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /api/commands`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandsQuery {
|
||||
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
}
|
||||
|
||||
@@ -304,22 +304,38 @@ function chatPage() {
|
||||
this._slashCommandsLoaded = true;
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
// Fetch slash commands from the unified registry (/api/commands?surface=web).
|
||||
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
|
||||
// the help panel and autocomplete stay in sync with the backend registry.
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
OpenFangAPI.get('/api/commands').then(function(data) {
|
||||
if (data.commands && data.commands.length) {
|
||||
// Build a set of known cmds to avoid duplicates
|
||||
var existing = {};
|
||||
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
|
||||
data.commands.forEach(function(c) {
|
||||
if (!existing[c.cmd]) {
|
||||
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
|
||||
existing[c.cmd] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(function() { /* silent — use hardcoded list */ });
|
||||
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
|
||||
var cmds = (data && data.commands) || [];
|
||||
if (!cmds.length) return;
|
||||
self.slashCommands = cmds.map(function(c) {
|
||||
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
|
||||
// Fall back to legacy { cmd, desc } shape so older shims keep working.
|
||||
if (c.name) {
|
||||
return {
|
||||
cmd: '/' + c.name,
|
||||
desc: c.description || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: 'registry'
|
||||
};
|
||||
}
|
||||
return {
|
||||
cmd: c.cmd,
|
||||
desc: c.desc || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: c.source || 'server'
|
||||
};
|
||||
});
|
||||
self._slashCommandsLoaded = true;
|
||||
}).catch(function() { /* silent — keep hardcoded fallback list */ });
|
||||
},
|
||||
|
||||
get filteredSlashCommands() {
|
||||
@@ -330,6 +346,44 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Render `/help` output grouped by category, mirroring the
|
||||
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
|
||||
// categories are not populated (pre-fetch hardcoded list).
|
||||
renderHelpText: function() {
|
||||
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
|
||||
var labels = {
|
||||
general: 'General', session: 'Session', model: 'Model', control: 'Control',
|
||||
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
|
||||
};
|
||||
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
|
||||
if (!anyCategorised) {
|
||||
return this.slashCommands.map(function(c) {
|
||||
return '`' + c.cmd + '` \u2014 ' + c.desc;
|
||||
}).join('\n');
|
||||
}
|
||||
var groups = {};
|
||||
this.slashCommands.forEach(function(c) {
|
||||
var cat = c.category || 'general';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
var lines = ['**Available commands:**'];
|
||||
order.forEach(function(cat) {
|
||||
var list = groups[cat];
|
||||
if (!list || !list.length) return;
|
||||
lines.push('');
|
||||
lines.push('**' + (labels[cat] || cat) + '**');
|
||||
list.forEach(function(c) {
|
||||
var aliasText = '';
|
||||
if (c.aliases && c.aliases.length) {
|
||||
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
|
||||
}
|
||||
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
|
||||
});
|
||||
});
|
||||
return lines.join('\n');
|
||||
},
|
||||
|
||||
// Clear any stuck typing indicator after 120s
|
||||
_resetTypingTimeout: function() {
|
||||
var self = this;
|
||||
@@ -355,7 +409,7 @@ function chatPage() {
|
||||
cmdArgs = cmdArgs || '';
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
break;
|
||||
case '/agents':
|
||||
|
||||
@@ -122,6 +122,7 @@ async fn start_test_server_with_provider(
|
||||
axum::routing::get(routes::list_workflow_runs),
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.route("/api/commands", axum::routing::get(routes::list_commands))
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
@@ -983,3 +984,161 @@ async fn test_auth_disabled_when_no_key() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/commands — unified command registry endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default (no surface query) returns web-surface commands.
|
||||
#[tokio::test]
|
||||
async fn test_commands_default_returns_web() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "web");
|
||||
|
||||
let commands = body["commands"].as_array().expect("commands is array");
|
||||
assert!(!commands.is_empty(), "web surface should have commands");
|
||||
|
||||
// Every entry has the documented shape.
|
||||
for c in commands {
|
||||
assert!(c["name"].is_string());
|
||||
assert!(c["aliases"].is_array());
|
||||
assert!(c["description"].is_string());
|
||||
assert!(c["category"].is_string());
|
||||
assert!(c["requires_agent"].is_boolean());
|
||||
}
|
||||
|
||||
// Sanity: web surface must include `/help` and `/verbose` and must NOT
|
||||
// include CLI-only `/kill`.
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"verbose"));
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// `?surface=cli` returns CLI-only commands and includes the alias array.
|
||||
#[tokio::test]
|
||||
async fn test_commands_cli_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=cli", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "cli");
|
||||
|
||||
let commands = body["commands"].as_array().unwrap();
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"kill"));
|
||||
assert!(names.contains(&"clear"));
|
||||
assert!(names.contains(&"exit"));
|
||||
// `start` is channel-only — must not appear on CLI.
|
||||
assert!(!names.contains(&"start"));
|
||||
|
||||
// `/exit` carries the `quit` alias.
|
||||
let exit = commands
|
||||
.iter()
|
||||
.find(|c| c["name"] == "exit")
|
||||
.expect("exit command must be present on CLI");
|
||||
let aliases = exit["aliases"].as_array().unwrap();
|
||||
assert!(
|
||||
aliases.iter().any(|a| a == "quit"),
|
||||
"quit alias should be attached to /exit"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=all` includes commands from every surface.
|
||||
#[tokio::test]
|
||||
async fn test_commands_all_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=all", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "all");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
// Surface-specific probes: all three unique-per-surface commands appear.
|
||||
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
|
||||
assert!(
|
||||
names.contains(&"start"),
|
||||
"channel-only /start missing from /all"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"verbose"),
|
||||
"web-only /verbose missing from /all"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=channel` returns channel commands only.
|
||||
#[tokio::test]
|
||||
async fn test_commands_channel_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=channel", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "channel");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"start"));
|
||||
// CLI-only must not appear here.
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// Unknown surface returns 400 with a JSON error body.
|
||||
#[tokio::test]
|
||||
async fn test_commands_invalid_surface_400() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=bogus", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let err = body["error"].as_str().unwrap_or_default();
|
||||
assert!(err.contains("bogus"), "error should mention the bad value: {err}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user