mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
feat: 新增平台连通性测试按钮,每个平台标签右侧显示测试结果
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||||
|
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
|
||||||
|
|
||||||
|
interface TestRequest {
|
||||||
|
agentId: string;
|
||||||
|
platform: string;
|
||||||
|
sessionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPlatformSession(req: TestRequest): Promise<{
|
||||||
|
agentId: string;
|
||||||
|
platform: string;
|
||||||
|
ok: boolean;
|
||||||
|
reply?: string;
|
||||||
|
error?: string;
|
||||||
|
elapsed: number;
|
||||||
|
}> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
const result = execSync(
|
||||||
|
`openclaw agent --agent ${req.agentId} --session-id "${req.sessionKey}" --message "Platform health check: reply with OK" --timeout 30 --json`,
|
||||||
|
{ timeout: 40000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const lines = result.split("\n");
|
||||||
|
const jsonStartIdx = lines.findIndex(l => l.trimStart().startsWith("{"));
|
||||||
|
if (jsonStartIdx === -1) {
|
||||||
|
return { agentId: req.agentId, platform: req.platform, ok: false, error: "No JSON in CLI output", elapsed };
|
||||||
|
}
|
||||||
|
const jsonStr = lines.slice(jsonStartIdx).join("\n");
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
const payloads = data?.result?.payloads || [];
|
||||||
|
const reply = payloads[0]?.text || "";
|
||||||
|
const durationMs = data?.result?.meta?.durationMs || elapsed;
|
||||||
|
const ok = data.status === "ok";
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentId: req.agentId,
|
||||||
|
platform: req.platform,
|
||||||
|
ok,
|
||||||
|
reply: reply ? reply.slice(0, 200) : (ok ? "(no reply)" : ""),
|
||||||
|
error: ok ? undefined : "Agent returned error status",
|
||||||
|
elapsed: durationMs,
|
||||||
|
};
|
||||||
|
} catch (execErr: any) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const isTimeout = execErr.killed || execErr.signal === "SIGTERM";
|
||||||
|
return {
|
||||||
|
agentId: req.agentId,
|
||||||
|
platform: req.platform,
|
||||||
|
ok: false,
|
||||||
|
error: isTimeout
|
||||||
|
? "Timeout: platform not responding (30s)"
|
||||||
|
: (execErr.stderr || execErr.message || "Unknown error").slice(0, 300),
|
||||||
|
elapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
|
||||||
|
const defaults = config.agents?.defaults || {};
|
||||||
|
const defaultModel = typeof defaults.model === "string"
|
||||||
|
? defaults.model
|
||||||
|
: defaults.model?.primary || "unknown";
|
||||||
|
const bindings = config.bindings || [];
|
||||||
|
const channels = config.channels || {};
|
||||||
|
const feishuAccounts = channels.feishu?.accounts || {};
|
||||||
|
|
||||||
|
let agentList = config.agents?.list || [];
|
||||||
|
if (agentList.length === 0) {
|
||||||
|
try {
|
||||||
|
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||||
|
const dirs = fs.readdirSync(agentsDir, { withFileTypes: true });
|
||||||
|
agentList = dirs
|
||||||
|
.filter((d: any) => d.isDirectory() && !d.name.startsWith("."))
|
||||||
|
.map((d: any) => ({ id: d.name }));
|
||||||
|
} catch {}
|
||||||
|
if (agentList.length === 0) {
|
||||||
|
agentList = [{ id: "main" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build test requests for each agent's platforms
|
||||||
|
const testRequests: TestRequest[] = [];
|
||||||
|
|
||||||
|
for (const agent of agentList) {
|
||||||
|
const id = agent.id;
|
||||||
|
|
||||||
|
// Find feishu sessions
|
||||||
|
const feishuBinding = bindings.find(
|
||||||
|
(b: any) => b.agentId === id && b.match?.channel === "feishu"
|
||||||
|
);
|
||||||
|
const hasFeishuBinding = !!feishuBinding;
|
||||||
|
const hasFeishuAccount = !!feishuAccounts[id];
|
||||||
|
|
||||||
|
if (hasFeishuBinding || hasFeishuAccount) {
|
||||||
|
// Find the most recent feishu DM session for this agent
|
||||||
|
try {
|
||||||
|
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
|
||||||
|
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
|
||||||
|
const sessions = JSON.parse(sessRaw);
|
||||||
|
let bestKey: string | null = null;
|
||||||
|
let bestTime = 0;
|
||||||
|
for (const [key, val] of Object.entries(sessions)) {
|
||||||
|
if (key.match(/^agent:[^:]+:feishu:direct:/)) {
|
||||||
|
const updatedAt = (val as any).updatedAt || 0;
|
||||||
|
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestKey) {
|
||||||
|
testRequests.push({ agentId: id, platform: "feishu", sessionKey: bestKey });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// main agent special: also check discord
|
||||||
|
if (id === "main" && channels.discord?.enabled) {
|
||||||
|
try {
|
||||||
|
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
|
||||||
|
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
|
||||||
|
const sessions = JSON.parse(sessRaw);
|
||||||
|
let bestKey: string | null = null;
|
||||||
|
let bestTime = 0;
|
||||||
|
for (const [key, val] of Object.entries(sessions)) {
|
||||||
|
if (key.match(/^agent:[^:]+:discord:direct:/)) {
|
||||||
|
const updatedAt = (val as any).updatedAt || 0;
|
||||||
|
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestKey) {
|
||||||
|
testRequests.push({ agentId: id, platform: "discord", sessionKey: bestKey });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-main agents with discord bindings
|
||||||
|
if (id !== "main") {
|
||||||
|
const discordBinding = bindings.find(
|
||||||
|
(b: any) => b.agentId === id && b.match?.channel === "discord"
|
||||||
|
);
|
||||||
|
if (discordBinding) {
|
||||||
|
try {
|
||||||
|
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
|
||||||
|
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
|
||||||
|
const sessions = JSON.parse(sessRaw);
|
||||||
|
let bestKey: string | null = null;
|
||||||
|
let bestTime = 0;
|
||||||
|
for (const [key, val] of Object.entries(sessions)) {
|
||||||
|
if (key.match(/^agent:[^:]+:discord:direct:/)) {
|
||||||
|
const updatedAt = (val as any).updatedAt || 0;
|
||||||
|
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestKey) {
|
||||||
|
testRequests.push({ agentId: id, platform: "discord", sessionKey: bestKey });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests in parallel
|
||||||
|
const results = await Promise.all(testRequests.map(testPlatformSession));
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-23
@@ -159,8 +159,16 @@ function ResponseTrendChart({ data, height = 180, t }: { data: DayStat[]; height
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 平台测试结果类型
|
||||||
|
interface PlatformTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
reply?: string;
|
||||||
|
error?: string;
|
||||||
|
elapsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 平台标签颜色
|
// 平台标签颜色
|
||||||
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc }) {
|
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t, testResult }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: PlatformTestResult | null }) {
|
||||||
const isFeishu = platform.name === "feishu";
|
const isFeishu = platform.name === "feishu";
|
||||||
|
|
||||||
let sessionKey: string;
|
let sessionKey: string;
|
||||||
@@ -175,24 +183,35 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { pl
|
|||||||
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
|
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div className="flex items-center gap-1.5">
|
||||||
href={sessionUrl}
|
<a
|
||||||
target="_blank"
|
href={sessionUrl}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
onClick={(e) => e.stopPropagation()}
|
rel="noopener noreferrer"
|
||||||
title={t("agent.openChat")}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${
|
title={t("agent.openChat")}
|
||||||
isFeishu
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${
|
||||||
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
|
isFeishu
|
||||||
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400"
|
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
|
||||||
}`}
|
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400"
|
||||||
>
|
}`}
|
||||||
{isFeishu ? t("platform.feishu") : t("platform.discord")}
|
>
|
||||||
{platform.accountId && (
|
{isFeishu ? t("platform.feishu") : t("platform.discord")}
|
||||||
<span className="opacity-60">({platform.accountId})</span>
|
{platform.accountId && (
|
||||||
|
<span className="opacity-60">({platform.accountId})</span>
|
||||||
|
)}
|
||||||
|
<span className="opacity-50 text-[10px]">↗</span>
|
||||||
|
</a>
|
||||||
|
{testResult === undefined ? (
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">--</span>
|
||||||
|
) : testResult === null ? (
|
||||||
|
<span className="text-xs text-[var(--text-muted)] animate-pulse">⏳</span>
|
||||||
|
) : testResult.ok ? (
|
||||||
|
<span className="text-green-400 text-sm cursor-help" title={`${testResult.elapsed}ms${testResult.reply ? ' · ' + testResult.reply : ''}`}>✅</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400 text-sm cursor-help" title={testResult.error || ''}>❌</span>
|
||||||
)}
|
)}
|
||||||
<span className="opacity-50 text-[10px]">↗</span>
|
</div>
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +240,7 @@ function ModelBadge({ model }: { model: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent 卡片
|
// Agent 卡片
|
||||||
function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null }) {
|
function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult, platformTestResults }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null; platformTestResults?: Record<string, PlatformTestResult | null> }) {
|
||||||
const sessionKey = `agent:${agent.id}:main`;
|
const sessionKey = `agent:${agent.id}:main`;
|
||||||
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
|
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
|
||||||
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
|
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
|
||||||
@@ -272,9 +291,13 @@ function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult }: { agent:
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
|
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{agent.platforms.map((p, i) => (
|
{agent.platforms.map((p, i) => {
|
||||||
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} />
|
const pKey = `${agent.id}:${p.name}`;
|
||||||
))}
|
const pResult = platformTestResults ? platformTestResults[pKey] : undefined;
|
||||||
|
return (
|
||||||
|
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} testResult={pResult} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,6 +362,8 @@ export default function Home() {
|
|||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> | null>(null);
|
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [platformTestResults, setPlatformTestResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
|
||||||
|
const [testingPlatforms, setTestingPlatforms] = useState(false);
|
||||||
|
|
||||||
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
|
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
|
||||||
|
|
||||||
@@ -389,6 +414,31 @@ export default function Home() {
|
|||||||
.finally(() => setTesting(false));
|
.finally(() => setTesting(false));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const testAllPlatforms = useCallback(() => {
|
||||||
|
setTestingPlatforms(true);
|
||||||
|
// Set all agent:platform combos to null (⏳)
|
||||||
|
const pending: Record<string, any> = {};
|
||||||
|
if (data) {
|
||||||
|
for (const a of data.agents) {
|
||||||
|
for (const p of a.platforms) {
|
||||||
|
pending[`${a.id}:${p.name}`] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPlatformTestResults(pending);
|
||||||
|
fetch("/api/test-platforms", { method: "POST" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.results) {
|
||||||
|
const map: Record<string, PlatformTestResult> = {};
|
||||||
|
for (const r of resp.results) map[`${r.agentId}:${r.platform}`] = r;
|
||||||
|
setPlatformTestResults(map);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setTestingPlatforms(false));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
// 定时刷新
|
// 定时刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
@@ -462,13 +512,20 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
{testing ? t("home.testingAll") : t("home.testAll")}
|
{testing ? t("home.testingAll") : t("home.testAll")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testAllPlatforms}
|
||||||
|
disabled={testingPlatforms}
|
||||||
|
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{testingPlatforms ? t("home.testingPlatforms") : t("home.testPlatforms")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片墙 */}
|
{/* 卡片墙 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{data.agents.map((agent) => (
|
{data.agents.map((agent) => (
|
||||||
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} testResult={testResults?.[agent.id]} />
|
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||||||
"home.discordChannel": "Discord 频道",
|
"home.discordChannel": "Discord 频道",
|
||||||
"home.bots": "个机器人",
|
"home.bots": "个机器人",
|
||||||
"home.noResponseData": "暂无响应时间数据",
|
"home.noResponseData": "暂无响应时间数据",
|
||||||
|
"home.testPlatforms": "🧪 测试平台连通",
|
||||||
|
"home.testingPlatforms": "⏳ 测试平台中...",
|
||||||
|
|
||||||
// agent card
|
// agent card
|
||||||
"agent.model": "模型",
|
"agent.model": "模型",
|
||||||
@@ -221,6 +223,8 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||||||
"home.discordChannel": "Discord Channel",
|
"home.discordChannel": "Discord Channel",
|
||||||
"home.bots": "bots",
|
"home.bots": "bots",
|
||||||
"home.noResponseData": "No response time data",
|
"home.noResponseData": "No response time data",
|
||||||
|
"home.testPlatforms": "🧪 Test Platforms",
|
||||||
|
"home.testingPlatforms": "⏳ Testing Platforms...",
|
||||||
|
|
||||||
// agent card
|
// agent card
|
||||||
"agent.model": "Model",
|
"agent.model": "Model",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user