PMFlow/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx

391 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;