feat(web): add orchestrator command system in chat interface (#521)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #521.
This commit is contained in:
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* useOrchestratorCommands hook
|
||||
*
|
||||
* Parses chat messages for `/command` prefixes and routes them to the
|
||||
* orchestrator proxy API routes instead of the LLM.
|
||||
*
|
||||
* Supported commands:
|
||||
* /status — GET /api/orchestrator/health
|
||||
* /agents — GET /api/orchestrator/agents
|
||||
* /jobs — GET /api/orchestrator/queue/stats
|
||||
* /queue — alias for /jobs
|
||||
* /pause — POST /api/orchestrator/queue/pause
|
||||
* /resume — POST /api/orchestrator/queue/resume
|
||||
* /help — Display available commands locally (no API call)
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrchestratorCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export const ORCHESTRATOR_COMMANDS: OrchestratorCommand[] = [
|
||||
{ name: "/status", description: "Show orchestrator health and status" },
|
||||
{ name: "/agents", description: "List all running agents" },
|
||||
{ name: "/jobs", description: "Show queue statistics", aliases: ["/queue"] },
|
||||
{ name: "/pause", description: "Pause the job queue" },
|
||||
{ name: "/resume", description: "Resume the job queue" },
|
||||
{ name: "/help", description: "Show available commands" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response shapes (loosely typed — orchestrator may vary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HealthResponse {
|
||||
status?: string;
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
ready?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id?: string;
|
||||
sessionKey?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
agentStatus?: string;
|
||||
startedAt?: string;
|
||||
label?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents?: Agent[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface QueueStats {
|
||||
pending?: number;
|
||||
active?: number;
|
||||
completed?: number;
|
||||
failed?: number;
|
||||
waiting?: number;
|
||||
delayed?: number;
|
||||
paused?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ActionResponse {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeId(): string {
|
||||
return `orch-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function makeMessage(content: string): Message {
|
||||
return {
|
||||
id: makeId(),
|
||||
role: "assistant",
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(command: string, detail: string): Message {
|
||||
return makeMessage(
|
||||
`**Error running \`${command}\`**\n\n${detail}\n\n_Check that the orchestrator is running and the API key is configured._`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatStatus(data: HealthResponse): string {
|
||||
if (data.error) {
|
||||
return `**Orchestrator Status**\n\nStatus: Not reachable\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const statusLabel = data.status ?? (data.ready === true ? "ready" : "unknown");
|
||||
const isReady =
|
||||
statusLabel === "ready" ||
|
||||
statusLabel === "ok" ||
|
||||
statusLabel === "healthy" ||
|
||||
data.ready === true;
|
||||
const badge = isReady ? "Ready" : "Not Ready";
|
||||
|
||||
const lines: string[] = [
|
||||
`**Orchestrator Status**\n`,
|
||||
`| Field | Value |`,
|
||||
`|---|---|`,
|
||||
`| Status | **${badge}** |`,
|
||||
];
|
||||
|
||||
if (data.version != null) {
|
||||
lines.push(`| Version | \`${data.version}\` |`);
|
||||
}
|
||||
|
||||
if (data.uptime != null) {
|
||||
const uptimeSec = Math.floor(data.uptime);
|
||||
const hours = Math.floor(uptimeSec / 3600);
|
||||
const mins = Math.floor((uptimeSec % 3600) / 60);
|
||||
const secs = uptimeSec % 60;
|
||||
const uptimeStr =
|
||||
hours > 0
|
||||
? `${String(hours)}h ${String(mins)}m ${String(secs)}s`
|
||||
: `${String(mins)}m ${String(secs)}s`;
|
||||
lines.push(`| Uptime | ${uptimeStr} |`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatAgents(raw: unknown): string {
|
||||
let agents: Agent[] = [];
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
agents = raw as Agent[];
|
||||
} else if (raw !== null && typeof raw === "object") {
|
||||
const obj = raw as AgentsResponse;
|
||||
if (obj.error) {
|
||||
return `**Agents**\n\nError: ${obj.error}`;
|
||||
}
|
||||
if (Array.isArray(obj.agents)) {
|
||||
agents = obj.agents;
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return "**Agents**\n\nNo agents currently running.";
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`**Agents** (${String(agents.length)} total)\n`,
|
||||
`| ID / Key | Status | Type / Channel | Started |`,
|
||||
`|---|---|---|---|`,
|
||||
];
|
||||
|
||||
for (const agent of agents) {
|
||||
const id = agent.id ?? agent.sessionKey ?? "—";
|
||||
const status = agent.agentStatus ?? agent.status ?? "—";
|
||||
const type = agent.type ?? agent.channel ?? "—";
|
||||
const started = agent.startedAt ? new Date(agent.startedAt).toLocaleString() : "—";
|
||||
lines.push(`| \`${id}\` | ${status} | ${type} | ${started} |`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatQueueStats(data: QueueStats): string {
|
||||
if (data.error) {
|
||||
return `**Queue Stats**\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const lines: string[] = [`**Queue Statistics**\n`, `| Metric | Count |`, `|---|---|`];
|
||||
|
||||
const metrics: [string, number | undefined][] = [
|
||||
["Pending", data.pending ?? data.waiting],
|
||||
["Active", data.active],
|
||||
["Delayed", data.delayed],
|
||||
["Completed", data.completed],
|
||||
["Failed", data.failed],
|
||||
];
|
||||
|
||||
for (const [label, value] of metrics) {
|
||||
if (value !== undefined) {
|
||||
lines.push(`| ${label} | ${String(value)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.paused === true) {
|
||||
lines.push("\n_Queue is currently **paused**._");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatAction(command: string, data: ActionResponse): string {
|
||||
if (data.error) {
|
||||
return `**${command}** failed.\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const verb = command === "/pause" ? "paused" : "resumed";
|
||||
const msg = data.message ?? data.status ?? `Queue ${verb} successfully.`;
|
||||
return `**Queue ${verb}**\n\n${msg}`;
|
||||
}
|
||||
|
||||
function formatHelp(): string {
|
||||
const lines: string[] = [
|
||||
"**Available Orchestrator Commands**\n",
|
||||
"| Command | Description |",
|
||||
"|---|---|",
|
||||
];
|
||||
|
||||
for (const cmd of ORCHESTRATOR_COMMANDS) {
|
||||
const name = cmd.aliases ? `${cmd.name} (${cmd.aliases.join(", ")})` : cmd.name;
|
||||
lines.push(`| \`${name}\` | ${cmd.description} |`);
|
||||
}
|
||||
|
||||
lines.push("\n_Commands starting with `/` are routed to the orchestrator instead of the LLM._");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCommandName(content: string): string | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const parts = trimmed.split(/\s+/);
|
||||
return parts[0]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseOrchestratorCommandsReturn {
|
||||
/**
|
||||
* Returns true if the content looks like an orchestrator command.
|
||||
*/
|
||||
isCommand: (content: string) => boolean;
|
||||
|
||||
/**
|
||||
* Execute an orchestrator command.
|
||||
* Returns a Message with formatted markdown output, or null if not a command.
|
||||
*/
|
||||
executeCommand: (content: string) => Promise<Message | null>;
|
||||
}
|
||||
|
||||
export function useOrchestratorCommands(): UseOrchestratorCommandsReturn {
|
||||
const isCommand = useCallback((content: string): boolean => {
|
||||
return content.trim().startsWith("/");
|
||||
}, []);
|
||||
|
||||
const executeCommand = useCallback(async (content: string): Promise<Message | null> => {
|
||||
const command = parseCommandName(content);
|
||||
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// /help — local, no network
|
||||
if (command === "/help") {
|
||||
return makeMessage(formatHelp());
|
||||
}
|
||||
|
||||
// /status
|
||||
if (command === "/status") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/health", { method: "GET" });
|
||||
const data = (await res.json()) as HealthResponse;
|
||||
return makeMessage(formatStatus(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/status", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /agents
|
||||
if (command === "/agents") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/agents", { method: "GET" });
|
||||
const data: unknown = await res.json();
|
||||
return makeMessage(formatAgents(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/agents", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /jobs or /queue
|
||||
if (command === "/jobs" || command === "/queue") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/stats", { method: "GET" });
|
||||
const data = (await res.json()) as QueueStats;
|
||||
return makeMessage(formatQueueStats(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage(command, detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /pause
|
||||
if (command === "/pause") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/pause", { method: "POST" });
|
||||
const data = (await res.json()) as ActionResponse;
|
||||
return makeMessage(formatAction("/pause", data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/pause", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /resume
|
||||
if (command === "/resume") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/resume", { method: "POST" });
|
||||
const data = (await res.json()) as ActionResponse;
|
||||
return makeMessage(formatAction("/resume", data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/resume", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command — show help hint
|
||||
return makeMessage(
|
||||
`Unknown command: \`${command}\`\n\nType \`/help\` to see available commands.`
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { isCommand, executeCommand };
|
||||
}
|
||||
Reference in New Issue
Block a user