391 lines
18 KiB
TypeScript
391 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
AlertCircle,
|
||
Bot,
|
||
CheckCircle2,
|
||
Loader2,
|
||
Play,
|
||
RefreshCcw,
|
||
ShieldAlert,
|
||
Wand2,
|
||
} from "lucide-react";
|
||
|
||
import { aiStewardApi, type OpenClawCommandDispatchResponse } from "@/api/aiSteward";
|
||
import { projectsApi, type ProjectSummary } from "@/api/projects";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { formatProjectNameWithId, toZhStatus } from "@/lib/display";
|
||
|
||
const EXAMPLE_COMMANDS = [
|
||
"把当前未分配的高优先级任务分给最合适的人,并通知相关成员。",
|
||
"根据当前负载重新平衡本项目任务,先 dry run 给出调整方案。",
|
||
"检查 blocked 任务,能恢复推进的直接切状态,不能恢复的通知负责人补充信息。",
|
||
];
|
||
|
||
const MANAGER_ROLES = new Set(["BOSS", "ADMIN"]);
|
||
|
||
function resultTone(status: string): string {
|
||
const key = String(status || "").trim().toLowerCase();
|
||
if (key === "applied") return "bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800";
|
||
if (key === "planned") return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800";
|
||
if (key === "skipped") return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800";
|
||
return "bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800";
|
||
}
|
||
|
||
function renderOperationLabel(operationCode: string): string {
|
||
const code = String(operationCode || "").trim().toLowerCase();
|
||
if (code === "task.create") return "任务创建";
|
||
if (code === "task.assign") return "任务分配";
|
||
if (code === "task.prioritize") return "优先级调整";
|
||
if (code === "task.status_transition") return "状态迁移";
|
||
if (code === "notification.create") return "通知创建";
|
||
if (code === "personnel.workload_update") return "工时容量调整";
|
||
return operationCode || "-";
|
||
}
|
||
|
||
interface OpenClawCommanderPanelProps {
|
||
currentUserRole?: string | null;
|
||
}
|
||
|
||
export function OpenClawCommanderPanel({ currentUserRole }: OpenClawCommanderPanelProps) {
|
||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||
const [projectLoading, setProjectLoading] = useState(false);
|
||
const [projectId, setProjectId] = useState("");
|
||
const [instruction, setInstruction] = useState("");
|
||
const [dryRun, setDryRun] = useState(true);
|
||
const [maxOperations, setMaxOperations] = useState("6");
|
||
const [dispatching, setDispatching] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [result, setResult] = useState<OpenClawCommandDispatchResponse | null>(null);
|
||
|
||
const canDispatch = MANAGER_ROLES.has(String(currentUserRole || "").trim().toUpperCase());
|
||
const selectedProject = useMemo(
|
||
() => projects.find((item) => String(item.id) === String(projectId)) || null,
|
||
[projectId, projects]
|
||
);
|
||
|
||
const loadProjects = async () => {
|
||
setProjectLoading(true);
|
||
try {
|
||
const payload = await projectsApi.getSummary();
|
||
const items = payload.projects || [];
|
||
setProjects(items);
|
||
if (!projectId && items[0]?.id) {
|
||
setProjectId(String(items[0].id));
|
||
}
|
||
} catch (loadError) {
|
||
console.error("load commander projects failed", loadError);
|
||
setError("加载项目列表失败");
|
||
} finally {
|
||
setProjectLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!canDispatch) {
|
||
return;
|
||
}
|
||
void loadProjects();
|
||
}, [canDispatch]);
|
||
|
||
const handleDispatch = async () => {
|
||
const parsedProjectId = Number(projectId);
|
||
const parsedMaxOperations = Number(maxOperations);
|
||
|
||
if (!Number.isInteger(parsedProjectId) || parsedProjectId <= 0) {
|
||
setError("请选择有效项目");
|
||
return;
|
||
}
|
||
if (!instruction.trim()) {
|
||
setError("请输入调度指令");
|
||
return;
|
||
}
|
||
if (!Number.isInteger(parsedMaxOperations) || parsedMaxOperations < 1 || parsedMaxOperations > 20) {
|
||
setError("max operations 仅支持 1-20");
|
||
return;
|
||
}
|
||
|
||
setDispatching(true);
|
||
setError(null);
|
||
try {
|
||
const payload = await aiStewardApi.dispatchCommand({
|
||
project_id: parsedProjectId,
|
||
instruction: instruction.trim(),
|
||
dry_run: dryRun,
|
||
max_operations: parsedMaxOperations,
|
||
});
|
||
setResult(payload);
|
||
if (!dryRun) {
|
||
void loadProjects();
|
||
}
|
||
} catch (dispatchError: any) {
|
||
const text = typeof dispatchError?.message === "string" ? dispatchError.message : "指挥调度失败";
|
||
setError(text);
|
||
} finally {
|
||
setDispatching(false);
|
||
}
|
||
};
|
||
|
||
if (!canDispatch) {
|
||
return (
|
||
<Card className="border-amber-200 dark:border-amber-800">
|
||
<CardContent className="py-10 text-center space-y-3">
|
||
<ShieldAlert className="w-8 h-8 mx-auto text-amber-500" />
|
||
<div className="text-lg font-semibold">Commander 仅开放给管理角色</div>
|
||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||
当前接口要求 `BOSS / ADMIN`,普通成员仍可通过任务看板或 AI 问答拿建议,但不能直接下发执行。
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<Card className="border-slate-200 dark:border-slate-800">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-11 h-11 rounded-2xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 flex items-center justify-center">
|
||
<Bot className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<CardTitle>OpenClaw Commander</CardTitle>
|
||
<CardDescription>直接下达自然语言指令,让指挥层规划并驱动 AI 管家执行。</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<Label>项目范围</Label>
|
||
<Button variant="outline" size="sm" onClick={() => void loadProjects()} disabled={projectLoading}>
|
||
{projectLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2 max-h-[460px] overflow-y-auto pr-1">
|
||
{projects.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700">
|
||
{projectLoading ? "正在加载项目..." : "暂无可调度项目"}
|
||
</div>
|
||
) : (
|
||
projects.map((project) => {
|
||
const active = String(project.id) === String(projectId);
|
||
return (
|
||
<button
|
||
key={project.id}
|
||
type="button"
|
||
onClick={() => setProjectId(String(project.id))}
|
||
className={`w-full rounded-xl border p-4 text-left transition-colors ${
|
||
active
|
||
? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
|
||
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:hover:border-slate-700"
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="font-medium">{formatProjectNameWithId(project)}</div>
|
||
<Badge variant={active ? "secondary" : "outline"}>{toZhStatus(project.status)}</Badge>
|
||
</div>
|
||
<div className={`mt-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
|
||
任务 {project.total_tasks} / 完成 {project.completed_tasks} / 进行中 {project.in_progress_tasks}
|
||
</div>
|
||
{project.description ? (
|
||
<div className={`mt-2 line-clamp-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
|
||
{project.description}
|
||
</div>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="openclaw-project-id">项目 ID</Label>
|
||
<Input
|
||
id="openclaw-project-id"
|
||
value={projectId}
|
||
onChange={(event) => setProjectId(event.target.value)}
|
||
placeholder="例如 1"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="openclaw-max-ops">Max Operations</Label>
|
||
<Input
|
||
id="openclaw-max-ops"
|
||
value={maxOperations}
|
||
onChange={(event) => setMaxOperations(event.target.value)}
|
||
inputMode="numeric"
|
||
placeholder="6"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedProject ? (
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/60">
|
||
<div className="font-medium">{formatProjectNameWithId(selectedProject)}</div>
|
||
<div className="mt-1 text-slate-600 dark:text-slate-400">
|
||
Active {selectedProject.in_progress_tasks} / Pending {selectedProject.pending_tasks} / Blocked 由执行结果返回。
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="openclaw-instruction">调度指令</Label>
|
||
<Textarea
|
||
id="openclaw-instruction"
|
||
rows={6}
|
||
value={instruction}
|
||
onChange={(event) => setInstruction(event.target.value)}
|
||
placeholder="例如:把当前未分配的高优先级任务分给最合适的人,并给新负责人发通知。"
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
{EXAMPLE_COMMANDS.map((item) => (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:text-slate-100"
|
||
onClick={() => setInstruction(item)}
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium">Dry Run</div>
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||
开启后只生成计划,不落库执行。
|
||
</div>
|
||
</div>
|
||
<Switch checked={dryRun} onCheckedChange={setDryRun} />
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
||
<div className="flex items-center gap-2">
|
||
<AlertCircle className="w-4 h-4" />
|
||
{error}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Button onClick={handleDispatch} disabled={dispatching || projectLoading} className="w-full bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200">
|
||
{dispatching ? (
|
||
<>
|
||
<Loader2 className="mr-2 w-4 h-4 animate-spin" />
|
||
Commander 执行中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Play className="mr-2 w-4 h-4" />
|
||
{dryRun ? "生成调度计划" : "执行调度指令"}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{result ? (
|
||
<Card className="border-slate-200 dark:border-slate-800">
|
||
<CardHeader>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<CardTitle className="mr-auto">执行结果</CardTitle>
|
||
<Badge variant="outline">{result.planner_status}</Badge>
|
||
<Badge variant="secondary">{result.prompt_role.role_name || result.prompt_role.role_key || "default"}</Badge>
|
||
<Badge variant={result.dry_run ? "outline" : "default"}>{result.dry_run ? "Dry Run" : "Executed"}</Badge>
|
||
</div>
|
||
<CardDescription>{result.summary || "OpenClaw 已完成规划。"}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">Trace</div>
|
||
<div className="mt-1 break-all text-sm font-medium">{result.trace_id}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">项目成员</div>
|
||
<div className="mt-1 text-sm font-medium">{result.member_count}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">计划操作</div>
|
||
<div className="mt-1 text-sm font-medium">{result.planned_operations}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="text-xs text-slate-500 dark:text-slate-400">可信度</div>
|
||
<div className="mt-1 text-sm font-medium">{Math.round((result.confidence || 0) * 100)}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
|
||
<div className="text-xs text-green-700 dark:text-green-300">Applied</div>
|
||
<div className="mt-1 text-2xl font-semibold text-green-800 dark:text-green-100">{result.execution.applied_count}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
|
||
<div className="text-xs text-blue-700 dark:text-blue-300">Requested</div>
|
||
<div className="mt-1 text-2xl font-semibold text-blue-800 dark:text-blue-100">{result.execution.requested_count}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
|
||
<div className="text-xs text-amber-700 dark:text-amber-300">Skipped</div>
|
||
<div className="mt-1 text-2xl font-semibold text-amber-800 dark:text-amber-100">{result.execution.skipped_count}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||
<div className="text-xs text-red-700 dark:text-red-300">Failed</div>
|
||
<div className="mt-1 text-2xl font-semibold text-red-800 dark:text-red-100">{result.execution.failed_count}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{result.execution.results.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed border-slate-300 p-5 text-sm text-slate-500 dark:border-slate-700">
|
||
当前没有可执行 operation,通常意味着上下文不足,或者指令被安全约束拦住了。
|
||
</div>
|
||
) : (
|
||
result.execution.results.map((item) => (
|
||
<div key={`${item.index}-${item.operation_code}-${item.idempotency_key || "no-key"}`} className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline">#{item.index}</Badge>
|
||
<div className="font-medium">{renderOperationLabel(item.operation_code)}</div>
|
||
<div className={`rounded-full border px-2.5 py-0.5 text-xs ${resultTone(item.status)}`}>
|
||
{item.status}
|
||
</div>
|
||
{item.entity_type && item.entity_id ? (
|
||
<Badge variant="secondary">
|
||
{item.entity_type}:{item.entity_id}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-2 flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||
{item.status === "applied" ? <CheckCircle2 className="mt-0.5 w-4 h-4 text-green-500" /> : <Wand2 className="mt-0.5 w-4 h-4 text-slate-400" />}
|
||
<div>{item.message}</div>
|
||
</div>
|
||
<pre className="mt-3 overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">
|
||
{JSON.stringify(item.payload, null, 2)}
|
||
</pre>
|
||
{item.trace_id ? (
|
||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">trace: {item.trace_id}</div>
|
||
) : null}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default OpenClawCommanderPanel;
|