mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
refactor agent card and improve model probe flow
This commit is contained in:
@@ -1,123 +1,91 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { DEFAULT_MODEL_PROBE_TIMEOUT_MS, parseModelRef, probeModel } from "@/lib/model-probe";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
|
||||
const PROBE_TIMEOUT_MS = DEFAULT_MODEL_PROBE_TIMEOUT_MS;
|
||||
|
||||
interface ProbeResult {
|
||||
provider?: string;
|
||||
type AgentConfig = {
|
||||
id: string;
|
||||
model?: string;
|
||||
mode?: "api_key" | "oauth" | string;
|
||||
status?: "ok" | "error" | "unknown" | string;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
};
|
||||
|
||||
function parseModelRef(modelStr: string) {
|
||||
const [providerId, ...rest] = modelStr.split("/");
|
||||
return { providerId, modelId: rest.join("/") };
|
||||
}
|
||||
function loadAgentList(config: any): AgentConfig[] {
|
||||
let agentList: AgentConfig[] = config?.agents?.list || [];
|
||||
if (agentList.length > 0) return agentList;
|
||||
|
||||
function parseJsonFromMixedOutput(output: string): any {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
if (output[i] !== "{") continue;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let j = i; j < output.length; j++) {
|
||||
const ch = output[j];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === "\\") escaped = true;
|
||||
else if (ch === "\"") inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = output.slice(i, j + 1).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to parse JSON output from openclaw models status --probe --json");
|
||||
try {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const dirs = fs.readdirSync(agentsDir, { withFileTypes: true });
|
||||
agentList = dirs
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
||||
.map((d) => ({ id: d.name }));
|
||||
} catch {}
|
||||
|
||||
if (agentList.length === 0) return [{ id: "main" }];
|
||||
return agentList;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
const defaults = config.agents?.defaults || {};
|
||||
const defaults = config?.agents?.defaults || {};
|
||||
const defaultModel = typeof defaults.model === "string"
|
||||
? defaults.model
|
||||
: defaults.model?.primary || "unknown";
|
||||
|
||||
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" }];
|
||||
}
|
||||
const agentList = loadAgentList(config);
|
||||
const modelProbeTasks = new Map<string, Promise<Awaited<ReturnType<typeof probeModel>>>>();
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
"openclaw",
|
||||
["models", "status", "--probe", "--json"],
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
}
|
||||
);
|
||||
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
|
||||
const probes: ProbeResult[] = parsed?.auth?.probes?.results || [];
|
||||
|
||||
const results = agentList.map((agent: any) => {
|
||||
for (const agent of agentList) {
|
||||
const modelStr = agent.model || defaultModel;
|
||||
const { providerId, modelId } = parseModelRef(modelStr);
|
||||
const fullModel = `${providerId}/${modelId}`;
|
||||
const key = `${providerId}/${modelId}`;
|
||||
if (!modelProbeTasks.has(key)) {
|
||||
modelProbeTasks.set(
|
||||
key,
|
||||
probeModel({ providerId, modelId, timeoutMs: PROBE_TIMEOUT_MS })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const exact =
|
||||
probes.find((p) => p.provider === providerId && p.model === fullModel) ||
|
||||
probes.find((p) => p.provider === providerId && typeof p.model === "string" && p.model.endsWith(`/${modelId}`));
|
||||
const matched = exact || probes.find((p) => p.provider === providerId);
|
||||
const modelProbeResults = new Map<string, Awaited<ReturnType<typeof probeModel>>>();
|
||||
for (const [key, task] of modelProbeTasks.entries()) {
|
||||
modelProbeResults.set(key, await task);
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
const results = agentList.map((agent) => {
|
||||
const modelStr = agent.model || defaultModel;
|
||||
const { providerId, modelId } = parseModelRef(modelStr);
|
||||
const key = `${providerId}/${modelId}`;
|
||||
const probe = modelProbeResults.get(key);
|
||||
if (!probe) {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
model: modelStr,
|
||||
ok: false,
|
||||
error: `No probe result for provider ${providerId}`,
|
||||
error: `No probe result for model ${key}`,
|
||||
elapsed: 0,
|
||||
status: "unknown",
|
||||
mode: "unknown",
|
||||
precision: "provider",
|
||||
source: "openclaw_provider_probe",
|
||||
};
|
||||
}
|
||||
|
||||
const ok = matched.status === "ok";
|
||||
return {
|
||||
agentId: agent.id,
|
||||
model: modelStr,
|
||||
ok,
|
||||
text: ok ? "OK (openclaw models status --probe)" : undefined,
|
||||
error: ok ? undefined : (matched.error || `Probe status: ${matched.status || "unknown"}`),
|
||||
elapsed: matched.latencyMs || 0,
|
||||
ok: probe.ok,
|
||||
text: probe.text,
|
||||
error: probe.error,
|
||||
elapsed: probe.elapsed,
|
||||
status: probe.status,
|
||||
mode: probe.mode,
|
||||
precision: probe.precision,
|
||||
source: probe.source,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -130,3 +98,4 @@ export async function POST() {
|
||||
export async function GET() {
|
||||
return POST();
|
||||
}
|
||||
|
||||
|
||||
+16
-88
@@ -1,101 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { DEFAULT_MODEL_PROBE_TIMEOUT_MS, probeModel } from "@/lib/model-probe";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface ProbeResult {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: "api_key" | "oauth" | string;
|
||||
status?: "ok" | "error" | "unknown" | string;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
function parseJsonFromMixedOutput(output: string): any {
|
||||
// `openclaw models status --json` may print warnings/logs before JSON.
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
if (output[i] !== "{") continue;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let j = i; j < output.length; j++) {
|
||||
const ch = output[j];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === "\\") escaped = true;
|
||||
else if (ch === "\"") inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = output.slice(i, j + 1).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to parse JSON output from openclaw models status --probe --json");
|
||||
}
|
||||
const PROBE_TIMEOUT_MS = DEFAULT_MODEL_PROBE_TIMEOUT_MS;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { provider: providerId, modelId } = await req.json();
|
||||
const { provider: providerIdRaw, modelId: modelIdRaw } = await req.json();
|
||||
const providerId = String(providerIdRaw || "").trim();
|
||||
const modelId = String(modelIdRaw || "").trim();
|
||||
if (!providerId || !modelId) {
|
||||
return NextResponse.json({ error: "Missing provider or modelId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
"openclaw",
|
||||
["models", "status", "--probe", "--json", "--probe-provider", String(providerId)],
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
}
|
||||
);
|
||||
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
|
||||
const results: ProbeResult[] = parsed?.auth?.probes?.results || [];
|
||||
const fullModel = `${providerId}/${modelId}`;
|
||||
|
||||
const exact =
|
||||
results.find((r) => r.provider === providerId && r.model === fullModel) ||
|
||||
results.find((r) => r.provider === providerId && typeof r.model === "string" && r.model.endsWith(`/${modelId}`));
|
||||
const matched = exact || results.find((r) => r.provider === providerId);
|
||||
|
||||
if (!matched) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: `No probe result for provider ${providerId}`,
|
||||
elapsed: Date.now() - startedAt,
|
||||
model: fullModel,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const ok = matched.status === "ok";
|
||||
const error = matched.error || (!ok ? `Probe status: ${matched.status || "unknown"}` : undefined);
|
||||
const result = await probeModel({ providerId, modelId, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
return NextResponse.json({
|
||||
ok,
|
||||
elapsed: matched.latencyMs ?? Date.now() - startedAt,
|
||||
model: matched.model || fullModel,
|
||||
mode: matched.mode || "unknown",
|
||||
status: matched.status || "unknown",
|
||||
error,
|
||||
text: ok ? "OK (openclaw models status --probe)" : undefined,
|
||||
ok: result.ok,
|
||||
elapsed: result.elapsed,
|
||||
model: result.model,
|
||||
mode: result.mode,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
text: result.text,
|
||||
precision: result.precision,
|
||||
source: result.source,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
@@ -104,3 +31,4 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { buildGatewayUrl } from "@/lib/gateway-url";
|
||||
|
||||
export interface AgentPlatform {
|
||||
name: string;
|
||||
accountId?: string;
|
||||
appId?: string;
|
||||
botOpenId?: string;
|
||||
botUserId?: string;
|
||||
}
|
||||
|
||||
export interface AgentCardSession {
|
||||
lastActive: number | null;
|
||||
totalTokens: number;
|
||||
contextTokens: number;
|
||||
sessionCount: number;
|
||||
todayAvgResponseMs: number;
|
||||
messageCount: number;
|
||||
weeklyResponseMs: number[];
|
||||
weeklyTokens: number[];
|
||||
}
|
||||
|
||||
export interface AgentCardAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
model: string;
|
||||
platforms: AgentPlatform[];
|
||||
session?: AgentCardSession;
|
||||
}
|
||||
|
||||
export interface PlatformTestResult {
|
||||
ok: boolean;
|
||||
reply?: string;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
export interface AgentModelTestResult {
|
||||
ok: boolean;
|
||||
text?: string;
|
||||
error?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
export interface AgentSessionTestResult {
|
||||
ok: boolean;
|
||||
reply?: string;
|
||||
error?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
type TFunc = (key: string) => string;
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (!ms) return "-";
|
||||
if (ms < 1000) return ms + "ms";
|
||||
return (ms / 1000).toFixed(1) + "s";
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorStatusWithCopy({ error, className }: { error?: string; className?: string }) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const errorText = (error || "Unknown error").trim() || "Unknown error";
|
||||
|
||||
const onCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ok = await copyText(errorText);
|
||||
setCopyState(ok ? "copied" : "failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`group relative inline-flex items-center ${className || ""}`}
|
||||
onMouseLeave={() => setCopyState("idle")}
|
||||
>
|
||||
<span className="text-red-400 text-sm cursor-help">❌</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-full top-1/2 z-40 hidden h-8 w-2 -translate-y-1/2 bg-transparent group-hover:block group-focus-within:block"
|
||||
/>
|
||||
<span className="absolute left-full top-1/2 z-50 ml-2 hidden w-max max-w-[min(24rem,calc(100vw-1rem))] -translate-y-1/2 rounded-md border border-red-500/30 bg-[var(--card)] px-2 py-1.5 text-xs text-[var(--text)] shadow-lg group-hover:block group-focus-within:block">
|
||||
<span className="inline-flex items-start gap-1.5">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text)] hover:border-[var(--accent)]"
|
||||
>
|
||||
{copyState === "copied" ? "已复制" : "复制"}
|
||||
</button>
|
||||
{copyState === "failed" && (
|
||||
<span className="text-[10px] text-amber-300">复制失败</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words">{errorText}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformBadge({
|
||||
platform,
|
||||
agentId,
|
||||
gatewayPort,
|
||||
gatewayToken,
|
||||
gatewayHost,
|
||||
t,
|
||||
testResult,
|
||||
}: {
|
||||
platform: AgentPlatform;
|
||||
agentId: string;
|
||||
gatewayPort: number;
|
||||
gatewayToken?: string;
|
||||
gatewayHost?: string;
|
||||
t: TFunc;
|
||||
testResult?: PlatformTestResult | null;
|
||||
}) {
|
||||
const pName = platform.name;
|
||||
const badgeWidthClass = "w-[8.25rem]";
|
||||
const remoteLogoSrc = pName === "feishu"
|
||||
? "https://cdn.simpleicons.org/lark/2E5BFF"
|
||||
: pName === "telegram"
|
||||
? "https://cdn.simpleicons.org/telegram/26A5E4"
|
||||
: pName === "whatsapp"
|
||||
? "https://cdn.simpleicons.org/whatsapp/25D366"
|
||||
: "https://cdn.simpleicons.org/discord/5865F2";
|
||||
const logoFallbackSrc = pName === "feishu"
|
||||
? "/assets/platform-logos/feishu-favicon.png?v=1"
|
||||
: pName === "telegram"
|
||||
? "/assets/platform-logos/telegram.svg"
|
||||
: pName === "whatsapp"
|
||||
? "/assets/platform-logos/whatsapp.svg"
|
||||
: "/assets/platform-logos/discord.svg";
|
||||
const logoSizeClass = pName === "feishu" ? "w-[1.09375rem] h-[1.09375rem]" : "w-3.5 h-3.5";
|
||||
|
||||
let sessionKey: string;
|
||||
if (pName === "feishu" && platform.botOpenId) {
|
||||
sessionKey = `agent:${agentId}:feishu:direct:${platform.botOpenId}`;
|
||||
} else if (pName === "discord" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:discord:direct:${platform.botUserId}`;
|
||||
} else if (pName === "telegram" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:telegram:direct:${platform.botUserId}`;
|
||||
} else if (pName === "whatsapp" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:whatsapp:direct:${platform.botUserId}`;
|
||||
} else {
|
||||
sessionKey = `agent:${agentId}:main`;
|
||||
}
|
||||
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
|
||||
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
|
||||
|
||||
const badgeStyle = pName === "feishu"
|
||||
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
|
||||
: pName === "telegram"
|
||||
? "bg-sky-500/20 text-sky-300 border border-sky-500/30 hover:bg-sky-500/40 hover:border-sky-400"
|
||||
: pName === "whatsapp"
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/40 hover:border-green-400"
|
||||
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400";
|
||||
|
||||
const labelRaw = pName === "feishu" ? t("platform.feishu")
|
||||
: pName === "telegram" ? t("platform.telegram")
|
||||
: pName === "whatsapp" ? t("platform.whatsapp")
|
||||
: t("platform.discord");
|
||||
const label = labelRaw.replace(/^[^\p{L}\p{N}]+/u, "").trim();
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 max-w-full">
|
||||
<a
|
||||
href={sessionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t("agent.openChat")}
|
||||
className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md min-w-0 ${badgeWidthClass} ${badgeStyle}`}
|
||||
>
|
||||
<img
|
||||
src={remoteLogoSrc}
|
||||
alt={`${label} logo`}
|
||||
className={`${logoSizeClass} shrink-0`}
|
||||
onError={(e) => {
|
||||
if (e.currentTarget.dataset.fallbackApplied === "1") return;
|
||||
e.currentTarget.dataset.fallbackApplied = "1";
|
||||
e.currentTarget.src = logoFallbackSrc;
|
||||
}}
|
||||
/>
|
||||
<span className="shrink-0">{label}</span>
|
||||
{pName === "feishu" && platform.accountId && (
|
||||
<span className="opacity-60 truncate max-w-[4.5rem]">({platform.accountId})</span>
|
||||
)}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
{testResult === undefined ? (
|
||||
<span className="inline-flex w-5 justify-end text-xs text-[var(--text-muted)]">--</span>
|
||||
) : testResult === null ? (
|
||||
<span className="inline-flex w-5 justify-end text-xs text-[var(--text-muted)] animate-pulse">⏳</span>
|
||||
) : testResult.ok ? (
|
||||
<span className="inline-flex w-5 justify-end text-green-400 text-sm cursor-help" title={`${testResult.elapsed}ms${testResult.detail ? " · " + testResult.detail : testResult.reply ? " · " + testResult.reply : ""}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={testResult.error} className="w-5 justify-end" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelBadge({ model, accessMode }: { model: string; accessMode?: "auth" | "api_key" }) {
|
||||
const [provider, modelName] = model.includes("/")
|
||||
? model.split("/", 2)
|
||||
: ["default", model];
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
"yunyi-claude": "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
minimax: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
volcengine: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
bailian: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
colors[provider] || "bg-gray-500/20 text-gray-300 border-gray-500/30"
|
||||
}`}
|
||||
>
|
||||
{modelName}{accessMode ? ` (${accessMode})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: { data: number[]; width?: number; height?: number; color?: string }) {
|
||||
const hasData = data.some(v => v > 0);
|
||||
if (!hasData) return null;
|
||||
|
||||
const validValues = data.filter(v => v > 0);
|
||||
let trending: "up" | "down" | "flat" = "flat";
|
||||
if (validValues.length >= 2) {
|
||||
const last = validValues[validValues.length - 1];
|
||||
const prev = validValues[validValues.length - 2];
|
||||
trending = last > prev ? "up" : last < prev ? "down" : "flat";
|
||||
}
|
||||
const color = fixedColor || (trending === "up" ? "#f87171" : trending === "down" ? "#4ade80" : "#f59e0b");
|
||||
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data.filter(v => v > 0), max);
|
||||
const range = max - min || 1;
|
||||
const pad = 2;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = pad + (i / (data.length - 1)) * (width - pad * 2);
|
||||
const y = v === 0 ? height - pad : (height - pad) - ((v - min) / range) * (height - pad * 2 - 2);
|
||||
return { x, y, v };
|
||||
});
|
||||
const line = pts.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
const area = `${pts[0].x},${height} ${line} ${pts[pts.length - 1].x},${height}`;
|
||||
const id = `spark-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg width={width} height={height} className="inline-block align-middle" aria-label={data.map(v => v ? formatMs(v) : "-").join(" → ")}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={area} fill={`url(#${id})`} />
|
||||
<polyline points={line} fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{pts.filter((p) => p.v > 0).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={2} fill={color} opacity={0.9} />
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStatusBadge({ state, t }: { state?: string; t: TFunc }) {
|
||||
const config: Record<string, { dot: string; text: string; color: string; pulse?: boolean }> = {
|
||||
working: { dot: "bg-green-400", text: t("agent.status.working"), color: "text-green-400", pulse: true },
|
||||
online: { dot: "bg-green-400", text: t("agent.status.online"), color: "text-green-400" },
|
||||
idle: { dot: "bg-yellow-400", text: t("agent.status.idle"), color: "text-yellow-400" },
|
||||
offline: { dot: "bg-red-400", text: t("agent.status.offline"), color: "text-red-400" },
|
||||
};
|
||||
const c = config[state || "offline"] || config.offline;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${c.color}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${c.dot} ${c.pulse ? "animate-pulse" : ""}`} />
|
||||
{c.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCard({
|
||||
agent,
|
||||
gatewayPort,
|
||||
gatewayToken,
|
||||
gatewayHost,
|
||||
t,
|
||||
testResult,
|
||||
platformTestResults,
|
||||
sessionTestResult,
|
||||
agentState,
|
||||
dmSessionResults,
|
||||
providerAccessModeMap,
|
||||
}: {
|
||||
agent: AgentCardAgent;
|
||||
gatewayPort: number;
|
||||
gatewayToken?: string;
|
||||
gatewayHost?: string;
|
||||
t: TFunc;
|
||||
testResult?: AgentModelTestResult | null;
|
||||
platformTestResults?: Record<string, PlatformTestResult | null>;
|
||||
sessionTestResult?: AgentSessionTestResult | null;
|
||||
agentState?: string;
|
||||
dmSessionResults?: Record<string, PlatformTestResult | null>;
|
||||
providerAccessModeMap?: Record<string, "auth" | "api_key">;
|
||||
}) {
|
||||
const sessionKey = `agent:${agent.id}:main`;
|
||||
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
|
||||
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
|
||||
const modelProvider = agent.model.includes("/") ? agent.model.split("/", 1)[0] : "default";
|
||||
const modelAccessMode = providerAccessModeMap?.[modelProvider];
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t("common.justNow");
|
||||
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${t("common.daysAgo")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-2.5 hover:border-[var(--accent)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xl">{agent.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">{agent.name}</h3>
|
||||
</div>
|
||||
<AgentStatusBadge state={agentState} t={t} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">Agent ID</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={sessionUrl} target="_blank" rel="noopener noreferrer" 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 bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40">
|
||||
{agent.id}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
{sessionTestResult === undefined ? (
|
||||
<span className="text-xs text-[var(--text-muted)]">--</span>
|
||||
) : sessionTestResult === null ? (
|
||||
<span className="text-xs text-[var(--text-muted)] animate-pulse">⏳</span>
|
||||
) : sessionTestResult.ok ? (
|
||||
<span className="text-green-400 text-sm cursor-help" title={`${sessionTestResult.elapsed}ms${sessionTestResult.reply ? " · " + sessionTestResult.reply : ""}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={sessionTestResult.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">{t("agent.model")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelBadge model={agent.model} accessMode={modelAccessMode} />
|
||||
{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" title={`${testResult.elapsed}ms${testResult.text ? " · " + testResult.text : ""}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={testResult.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">{t("agent.platform")}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{agent.platforms.map((p, i) => {
|
||||
const pKey = `${agent.id}:${p.name}`;
|
||||
const pResult = platformTestResults ? platformTestResults[pKey] : undefined;
|
||||
const dmKey = `${agent.id}:${p.name}`;
|
||||
const dmResult = dmSessionResults ? dmSessionResults[dmKey] : undefined;
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-2 items-center gap-2">
|
||||
<PlatformBadge platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} gatewayHost={gatewayHost} t={t} testResult={pResult} />
|
||||
<div className="flex justify-end">
|
||||
{dmResult === undefined ? (
|
||||
<span className="text-sm text-[var(--text-muted)]">DM Session: --</span>
|
||||
) : dmResult === null ? (
|
||||
<span className="text-sm text-[var(--text-muted)] animate-pulse">DM Session: ⏳</span>
|
||||
) : dmResult.ok ? (
|
||||
<span className="text-green-400 text-sm cursor-help" title={`DM Session ${dmResult.elapsed}ms${dmResult.detail ? " · " + dmResult.detail : ""}`}>DM Session: ✅</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm inline-flex items-center gap-1">
|
||||
DM Session:
|
||||
<ErrorStatusWithCopy error={dmResult.error} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.session && (
|
||||
<div className="pt-1 mt-1 border-t border-[var(--border)]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.sessionCount")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/sessions?agent=${agent.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
{agent.session.sessionCount} →
|
||||
</a>
|
||||
<a
|
||||
href={`/stats?agent=${agent.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--accent)] hover:underline cursor-pointer text-[10px]"
|
||||
>
|
||||
📊 {t("agent.stats")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.messageCount")}</span>
|
||||
<span className="text-[var(--text)]">{agent.session.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.tokenUsage")}</span>
|
||||
{agent.session.weeklyTokens && <MiniSparkline data={agent.session.weeklyTokens} color="#4ade80" />}
|
||||
<span className="text-[var(--text)] cursor-help" title={t("agent.totalTokenTip")}>{formatTokens(agent.session.totalTokens)}</span>
|
||||
</div>
|
||||
{agent.session.lastActive && (
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.lastActive")}</span>
|
||||
<span className="text-[var(--text)]">{formatTimeAgo(agent.session.lastActive)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.todayAvgResponse")}</span>
|
||||
{agent.session.weeklyResponseMs && <MiniSparkline data={agent.session.weeklyResponseMs} />}
|
||||
{(() => {
|
||||
const val = agent.session.todayAvgResponseMs;
|
||||
const weekly = agent.session.weeklyResponseMs || [];
|
||||
const validVals = weekly.filter(v => v > 0);
|
||||
let arrow = "";
|
||||
if (validVals.length >= 2) {
|
||||
const last = validVals[validVals.length - 1];
|
||||
const prev = validVals[validVals.length - 2];
|
||||
arrow = last > prev ? "↗" : last < prev ? "↘" : "";
|
||||
}
|
||||
const colorClass = !val ? "text-[var(--text-muted)]"
|
||||
: val > 50000 ? "text-red-400"
|
||||
: val > 30000 ? "text-yellow-400"
|
||||
: "text-green-400";
|
||||
return (
|
||||
<span title={t("agent.todayAvgResponseTip")} className={`font-mono cursor-help ${colorClass}`}>
|
||||
{val ? formatMs(val) : "--"}{arrow && <span className="ml-0.5">{arrow}</span>}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-49
@@ -91,61 +91,50 @@ export default function ModelsPage() {
|
||||
|
||||
const testAllModels = async () => {
|
||||
if (!data) return;
|
||||
const providerModels: Record<string, string[]> = {};
|
||||
const modelTargets: Array<{ providerId: string; modelId: string; key: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const p of data.providers) {
|
||||
if (p.models.length > 0) {
|
||||
providerModels[p.id] = Array.from(new Set(p.models.map((m) => m.id)));
|
||||
} else {
|
||||
const knownModels = Object.values(modelStats).filter(s => s.provider === p.id);
|
||||
providerModels[p.id] = Array.from(new Set(knownModels.map((s) => s.modelId)));
|
||||
const modelIds = p.models.length > 0
|
||||
? Array.from(new Set(p.models.map((m) => m.id)))
|
||||
: Array.from(new Set(Object.values(modelStats).filter(s => s.provider === p.id).map((s) => s.modelId)));
|
||||
for (const modelId of modelIds) {
|
||||
const key = `${p.id}/${modelId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
modelTargets.push({ providerId: p.id, modelId, key });
|
||||
}
|
||||
}
|
||||
|
||||
if (modelTargets.length === 0) return;
|
||||
|
||||
setTesting((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const t of modelTargets) next[t.key] = true;
|
||||
return next;
|
||||
});
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const t of modelTargets) delete next[t.key];
|
||||
return next;
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(providerModels)
|
||||
.filter(([, modelIds]) => modelIds.length > 0)
|
||||
.map(async ([providerId, modelIds]) => {
|
||||
const keys = modelIds.map((id) => `${providerId}/${id}`);
|
||||
const probeModelId = modelIds[0];
|
||||
|
||||
setTesting((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) next[key] = true;
|
||||
return next;
|
||||
modelTargets.map(async ({ providerId, modelId, key }) => {
|
||||
try {
|
||||
const resp = await fetch("/api/test-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, modelId }),
|
||||
});
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) delete next[key];
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/test-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, modelId: probeModelId }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) next[key] = result;
|
||||
return next;
|
||||
});
|
||||
} catch (err: any) {
|
||||
const result = { ok: false, error: err.message, elapsed: 0 };
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) next[key] = result;
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setTesting((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) next[key] = false;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
const result = await resp.json();
|
||||
setTestResults((prev) => ({ ...prev, [key]: result }));
|
||||
} catch (err: any) {
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: false, error: err.message, elapsed: 0 } }));
|
||||
} finally {
|
||||
setTesting((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+9
-421
@@ -2,8 +2,14 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { buildGatewayUrl } from "@/lib/gateway-url";
|
||||
import { GatewayStatus } from "./gateway-status";
|
||||
import {
|
||||
AgentCard,
|
||||
ModelBadge,
|
||||
type PlatformTestResult,
|
||||
type AgentModelTestResult,
|
||||
type AgentSessionTestResult,
|
||||
} from "./components/agent-card";
|
||||
|
||||
interface Platform {
|
||||
name: string;
|
||||
@@ -83,74 +89,6 @@ function formatMs(ms: number): string {
|
||||
return (ms / 1000).toFixed(1) + "s";
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorStatusWithCopy({ error, className }: { error?: string; className?: string }) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const errorText = (error || "Unknown error").trim() || "Unknown error";
|
||||
|
||||
const onCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ok = await copyText(errorText);
|
||||
setCopyState(ok ? "copied" : "failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`group relative inline-flex items-center ${className || ""}`}
|
||||
onMouseLeave={() => setCopyState("idle")}
|
||||
>
|
||||
<span className="text-red-400 text-sm cursor-help">❌</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-full top-1/2 z-40 hidden h-8 w-2 -translate-y-1/2 bg-transparent group-hover:block group-focus-within:block"
|
||||
/>
|
||||
<span className="absolute left-full top-1/2 z-50 ml-2 hidden w-max max-w-[min(24rem,calc(100vw-1rem))] -translate-y-1/2 rounded-md border border-red-500/30 bg-[var(--card)] px-2 py-1.5 text-xs text-[var(--text)] shadow-lg group-hover:block group-focus-within:block">
|
||||
<span className="inline-flex items-start gap-1.5">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text)] hover:border-[var(--accent)]"
|
||||
>
|
||||
{copyState === "copied" ? "已复制" : "复制"}
|
||||
</button>
|
||||
{copyState === "failed" && (
|
||||
<span className="text-[10px] text-amber-300">复制失败</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words">{errorText}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 趋势折线图
|
||||
function TrendChart({ data, lines, height = 180, t }: { data: DayStat[]; lines: { key: keyof DayStat; color: string; label: string }[]; height?: number; t: TFunc }) {
|
||||
if (data.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">{t("common.noData")}</div>;
|
||||
@@ -241,356 +179,6 @@ function ResponseTrendChart({ data, height = 180, t }: { data: DayStat[]; height
|
||||
);
|
||||
}
|
||||
|
||||
// 平台测试结果类型
|
||||
interface PlatformTestResult {
|
||||
ok: boolean;
|
||||
reply?: string;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
// 平台标签颜色
|
||||
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, gatewayHost, t, testResult }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; gatewayHost?: string; t: TFunc; testResult?: PlatformTestResult | null }) {
|
||||
const pName = platform.name;
|
||||
const badgeWidthClass = "w-[8.25rem]";
|
||||
const remoteLogoSrc = pName === "feishu"
|
||||
? "https://cdn.simpleicons.org/lark/2E5BFF"
|
||||
: pName === "telegram"
|
||||
? "https://cdn.simpleicons.org/telegram/26A5E4"
|
||||
: pName === "whatsapp"
|
||||
? "https://cdn.simpleicons.org/whatsapp/25D366"
|
||||
: "https://cdn.simpleicons.org/discord/5865F2";
|
||||
const logoFallbackSrc = pName === "feishu"
|
||||
? "/assets/platform-logos/feishu-favicon.png?v=1"
|
||||
: pName === "telegram"
|
||||
? "/assets/platform-logos/telegram.svg"
|
||||
: pName === "whatsapp"
|
||||
? "/assets/platform-logos/whatsapp.svg"
|
||||
: "/assets/platform-logos/discord.svg";
|
||||
const logoSizeClass = pName === "feishu" ? "w-[1.09375rem] h-[1.09375rem]" : "w-3.5 h-3.5";
|
||||
|
||||
let sessionKey: string;
|
||||
if (pName === "feishu" && platform.botOpenId) {
|
||||
sessionKey = `agent:${agentId}:feishu:direct:${platform.botOpenId}`;
|
||||
} else if (pName === "discord" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:discord:direct:${platform.botUserId}`;
|
||||
} else if (pName === "telegram" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:telegram:direct:${platform.botUserId}`;
|
||||
} else if (pName === "whatsapp" && platform.botUserId) {
|
||||
sessionKey = `agent:${agentId}:whatsapp:direct:${platform.botUserId}`;
|
||||
} else {
|
||||
sessionKey = `agent:${agentId}:main`;
|
||||
}
|
||||
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
|
||||
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
|
||||
|
||||
const badgeStyle = pName === "feishu"
|
||||
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
|
||||
: pName === "telegram"
|
||||
? "bg-sky-500/20 text-sky-300 border border-sky-500/30 hover:bg-sky-500/40 hover:border-sky-400"
|
||||
: pName === "whatsapp"
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/40 hover:border-green-400"
|
||||
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400";
|
||||
|
||||
const labelRaw = pName === "feishu" ? t("platform.feishu")
|
||||
: pName === "telegram" ? t("platform.telegram")
|
||||
: pName === "whatsapp" ? t("platform.whatsapp")
|
||||
: t("platform.discord");
|
||||
const label = labelRaw.replace(/^[^\p{L}\p{N}]+/u, "").trim();
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 max-w-full">
|
||||
<a
|
||||
href={sessionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t("agent.openChat")}
|
||||
className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md min-w-0 ${badgeWidthClass} ${badgeStyle}`}
|
||||
>
|
||||
<img
|
||||
src={remoteLogoSrc}
|
||||
alt={`${label} logo`}
|
||||
className={`${logoSizeClass} shrink-0`}
|
||||
onError={(e) => {
|
||||
if (e.currentTarget.dataset.fallbackApplied === "1") return;
|
||||
e.currentTarget.dataset.fallbackApplied = "1";
|
||||
e.currentTarget.src = logoFallbackSrc;
|
||||
}}
|
||||
/>
|
||||
<span className="shrink-0">{label}</span>
|
||||
{pName === "feishu" && platform.accountId && (
|
||||
<span className="opacity-60 truncate max-w-[4.5rem]">({platform.accountId})</span>
|
||||
)}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
{testResult === undefined ? (
|
||||
<span className="inline-flex w-5 justify-end text-xs text-[var(--text-muted)]">--</span>
|
||||
) : testResult === null ? (
|
||||
<span className="inline-flex w-5 justify-end text-xs text-[var(--text-muted)] animate-pulse">⏳</span>
|
||||
) : testResult.ok ? (
|
||||
<span className="inline-flex w-5 justify-end text-green-400 text-sm cursor-help" title={`${testResult.elapsed}ms${testResult.detail ? ' · ' + testResult.detail : testResult.reply ? ' · ' + testResult.reply : ''}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={testResult.error} className="w-5 justify-end" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 模型标签
|
||||
function ModelBadge({ model, accessMode }: { model: string; accessMode?: "auth" | "api_key" }) {
|
||||
const [provider, modelName] = model.includes("/")
|
||||
? model.split("/", 2)
|
||||
: ["default", model];
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
"yunyi-claude": "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
minimax: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
volcengine: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
bailian: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
colors[provider] || "bg-gray-500/20 text-gray-300 border-gray-500/30"
|
||||
}`}
|
||||
>
|
||||
{modelName}{accessMode ? ` (${accessMode})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 迷你曲线图 (sparkline)
|
||||
function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: { data: number[]; width?: number; height?: number; color?: string }) {
|
||||
const hasData = data.some(v => v > 0);
|
||||
if (!hasData) return null;
|
||||
|
||||
// 判断趋势:最后一天 vs 前一天(跳过0值找最近两个有效值)
|
||||
const validValues = data.filter(v => v > 0);
|
||||
let trending: "up" | "down" | "flat" = "flat";
|
||||
if (validValues.length >= 2) {
|
||||
const last = validValues[validValues.length - 1];
|
||||
const prev = validValues[validValues.length - 2];
|
||||
trending = last > prev ? "up" : last < prev ? "down" : "flat";
|
||||
}
|
||||
const color = fixedColor || (trending === "up" ? "#f87171" : trending === "down" ? "#4ade80" : "#f59e0b");
|
||||
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data.filter(v => v > 0), max);
|
||||
const range = max - min || 1;
|
||||
const pad = 2;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = pad + (i / (data.length - 1)) * (width - pad * 2);
|
||||
const y = v === 0 ? height - pad : (height - pad) - ((v - min) / range) * (height - pad * 2 - 2);
|
||||
return { x, y, v };
|
||||
});
|
||||
const line = pts.map(p => `${p.x},${p.y}`).join(" ");
|
||||
const area = `${pts[0].x},${height} ${line} ${pts[pts.length - 1].x},${height}`;
|
||||
const id = `spark-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg width={width} height={height} className="inline-block align-middle" aria-label={data.map(v => v ? formatMs(v) : '-').join(' → ')}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={area} fill={`url(#${id})`} />
|
||||
<polyline points={line} fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{pts.filter(p => p.v > 0).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={2} fill={color} opacity={0.9} />
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent 状态标签
|
||||
function AgentStatusBadge({ state, t }: { state?: string; t: TFunc }) {
|
||||
const config: Record<string, { dot: string; text: string; color: string; pulse?: boolean }> = {
|
||||
working: { dot: "bg-green-400", text: t("agent.status.working"), color: "text-green-400", pulse: true },
|
||||
online: { dot: "bg-green-400", text: t("agent.status.online"), color: "text-green-400" },
|
||||
idle: { dot: "bg-yellow-400", text: t("agent.status.idle"), color: "text-yellow-400" },
|
||||
offline: { dot: "bg-red-400", text: t("agent.status.offline"), color: "text-red-400" },
|
||||
};
|
||||
const c = config[state || "offline"] || config.offline;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${c.color}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${c.dot} ${c.pulse ? "animate-pulse" : ""}`} />
|
||||
{c.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent 卡片
|
||||
function AgentCard({ agent, gatewayPort, gatewayToken, gatewayHost, t, testResult, platformTestResults, sessionTestResult, agentState, dmSessionResults, providerAccessModeMap }: { agent: Agent; gatewayPort: number; gatewayToken?: string; gatewayHost?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null; platformTestResults?: Record<string, PlatformTestResult | null>; sessionTestResult?: { ok: boolean; reply?: string; error?: string; elapsed: number } | null; agentState?: string; dmSessionResults?: Record<string, PlatformTestResult | null>; providerAccessModeMap?: Record<string, "auth" | "api_key"> }) {
|
||||
const sessionKey = `agent:${agent.id}:main`;
|
||||
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
|
||||
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
|
||||
const modelProvider = agent.model.includes("/") ? agent.model.split("/", 1)[0] : "default";
|
||||
const modelAccessMode = providerAccessModeMap?.[modelProvider];
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t("common.justNow");
|
||||
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${t("common.daysAgo")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-2.5 hover:border-[var(--accent)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xl">{agent.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">{agent.name}</h3>
|
||||
</div>
|
||||
<AgentStatusBadge state={agentState} t={t} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">Agent ID</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={sessionUrl} target="_blank" rel="noopener noreferrer" 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 bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40">
|
||||
{agent.id}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
{sessionTestResult === undefined ? (
|
||||
<span className="text-xs text-[var(--text-muted)]">--</span>
|
||||
) : sessionTestResult === null ? (
|
||||
<span className="text-xs text-[var(--text-muted)] animate-pulse">⏳</span>
|
||||
) : sessionTestResult.ok ? (
|
||||
<span className="text-green-400 text-sm cursor-help" title={`${sessionTestResult.elapsed}ms${sessionTestResult.reply ? ' · ' + sessionTestResult.reply : ''}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={sessionTestResult.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">{t("agent.model")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelBadge model={agent.model} accessMode={modelAccessMode} />
|
||||
{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" title={`${testResult.elapsed}ms${testResult.text ? ' · ' + testResult.text : ''}`}>✅</span>
|
||||
) : (
|
||||
<ErrorStatusWithCopy error={testResult.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block">{t("agent.platform")}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{agent.platforms.map((p, i) => {
|
||||
const pKey = `${agent.id}:${p.name}`;
|
||||
const pResult = platformTestResults ? platformTestResults[pKey] : undefined;
|
||||
const dmKey = `${agent.id}:${p.name}`;
|
||||
const dmResult = dmSessionResults ? dmSessionResults[dmKey] : undefined;
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-2 items-center gap-2">
|
||||
<PlatformBadge platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} gatewayHost={gatewayHost} t={t} testResult={pResult} />
|
||||
<div className="flex justify-end">
|
||||
{dmResult === undefined ? (
|
||||
<span className="text-sm text-[var(--text-muted)]">DM Session: --</span>
|
||||
) : dmResult === null ? (
|
||||
<span className="text-sm text-[var(--text-muted)] animate-pulse">DM Session: ⏳</span>
|
||||
) : dmResult.ok ? (
|
||||
<span className="text-green-400 text-sm cursor-help" title={`DM Session ${dmResult.elapsed}ms${dmResult.detail ? ' · ' + dmResult.detail : ''}`}>DM Session: ✅</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm inline-flex items-center gap-1">
|
||||
DM Session:
|
||||
<ErrorStatusWithCopy error={dmResult.error} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.session && (
|
||||
<div className="pt-1 mt-1 border-t border-[var(--border)]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.sessionCount")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/sessions?agent=${agent.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
{agent.session.sessionCount} →
|
||||
</a>
|
||||
<a
|
||||
href={`/stats?agent=${agent.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--accent)] hover:underline cursor-pointer text-[10px]"
|
||||
>
|
||||
📊 {t("agent.stats")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.messageCount")}</span>
|
||||
<span className="text-[var(--text)]">{agent.session.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.tokenUsage")}</span>
|
||||
{agent.session.weeklyTokens && <MiniSparkline data={agent.session.weeklyTokens} color="#4ade80" />}
|
||||
<span className="text-[var(--text)] cursor-help" title={t("agent.totalTokenTip")}>{formatTokens(agent.session.totalTokens)}</span>
|
||||
</div>
|
||||
{agent.session.lastActive && (
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.lastActive")}</span>
|
||||
<span className="text-[var(--text)]">{formatTimeAgo(agent.session.lastActive)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">{t("agent.todayAvgResponse")}</span>
|
||||
{agent.session.weeklyResponseMs && <MiniSparkline data={agent.session.weeklyResponseMs} />}
|
||||
{(() => {
|
||||
const val = agent.session.todayAvgResponseMs;
|
||||
const weekly = agent.session.weeklyResponseMs || [];
|
||||
const validVals = weekly.filter(v => v > 0);
|
||||
let arrow = "";
|
||||
if (validVals.length >= 2) {
|
||||
const last = validVals[validVals.length - 1];
|
||||
const prev = validVals[validVals.length - 2];
|
||||
arrow = last > prev ? "↗" : last < prev ? "↘" : "";
|
||||
}
|
||||
const colorClass = !val ? "text-[var(--text-muted)]"
|
||||
: val > 50000 ? "text-red-400"
|
||||
: val > 30000 ? "text-yellow-400"
|
||||
: "text-green-400";
|
||||
return (
|
||||
<span title={t("agent.todayAvgResponseTip")} className={`font-mono cursor-help ${colorClass}`}>
|
||||
{val ? formatMs(val) : "--"}{arrow && <span className="ml-0.5">{arrow}</span>}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* test result moved inline next to model badge */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigData | null>(cachedHomeData);
|
||||
@@ -601,11 +189,11 @@ export default function Home() {
|
||||
const [allStats, setAllStats] = useState<AllStats | null>(cachedHomeAllStats);
|
||||
const [statsRange, setStatsRange] = useState<TimeRange>("daily");
|
||||
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, AgentModelTestResult | null> | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [platformTestResults, setPlatformTestResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
|
||||
const [testingPlatforms, setTestingPlatforms] = useState(false);
|
||||
const [sessionTestResults, setSessionTestResults] = useState<Record<string, { ok: boolean; reply?: string; error?: string; elapsed: number } | null> | null>(null);
|
||||
const [sessionTestResults, setSessionTestResults] = useState<Record<string, AgentSessionTestResult | null> | null>(null);
|
||||
const [testingSessions, setTestingSessions] = useState(false);
|
||||
const [dmSessionResults, setDmSessionResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
|
||||
const [testingDmSessions, setTestingDmSessions] = useState(false);
|
||||
|
||||
+106
-27
@@ -24,6 +24,13 @@ import { loadCharacterPNGs, loadWallPNG } from '@/lib/pixel-office/sprites/pngLo
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
import { EditorToolbar } from './components/EditorToolbar'
|
||||
import { EditActionBar } from './components/EditActionBar'
|
||||
import {
|
||||
AgentCard,
|
||||
type AgentCardAgent,
|
||||
type AgentModelTestResult,
|
||||
type AgentSessionTestResult,
|
||||
type PlatformTestResult,
|
||||
} from '../components/agent-card'
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
@@ -55,6 +62,8 @@ type AgentStats = {
|
||||
lastActive: number | null
|
||||
}
|
||||
|
||||
type ConfigAgentCard = AgentCardAgent
|
||||
|
||||
function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: { data: number[]; width?: number; height?: number; color?: string }) {
|
||||
const hasData = data.some(v => v > 0)
|
||||
if (!hasData) return null
|
||||
@@ -211,6 +220,7 @@ export default function PixelOfficePage() {
|
||||
const [hoveredAgentId, setHoveredAgentId] = useState<number | null>(null)
|
||||
const mousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const agentStatsRef = useRef<Map<string, AgentStats>>(new Map())
|
||||
const configAgentsRef = useRef<Map<string, ConfigAgentCard>>(new Map())
|
||||
const contributionsRef = useRef<ContributionData | null>(null)
|
||||
const photographRef = useRef<HTMLImageElement | null>(null)
|
||||
const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 })
|
||||
@@ -224,6 +234,7 @@ export default function PixelOfficePage() {
|
||||
const gatewayDownStreakRef = useRef(0)
|
||||
const gatewayDegradedStreakRef = useRef(0)
|
||||
const gatewayHealthyStreakRef = useRef(0)
|
||||
const providerAccessModeRef = useRef<Record<string, 'auth' | 'api_key'>>({})
|
||||
const providersRef = useRef<Array<{ id: string; api: string; models: Array<{ id: string; name: string; contextWindow?: number }>; usedBy: Array<{ id: string; emoji: string; name: string }> }>>([])
|
||||
const [isEditMode, setIsEditMode] = useState(cachedIsEditMode)
|
||||
const [soundOn, setSoundOn] = useState(true)
|
||||
@@ -241,6 +252,10 @@ export default function PixelOfficePage() {
|
||||
const [versionLoading, setVersionLoading] = useState(false)
|
||||
const [versionLoadFailed, setVersionLoadFailed] = useState(false)
|
||||
const [showIdleRank, setShowIdleRank] = useState(false)
|
||||
const [cachedModelTestResults, setCachedModelTestResults] = useState<Record<string, AgentModelTestResult | null> | null>(null)
|
||||
const [cachedPlatformTestResults, setCachedPlatformTestResults] = useState<Record<string, PlatformTestResult | null> | null>(null)
|
||||
const [cachedSessionTestResults, setCachedSessionTestResults] = useState<Record<string, AgentSessionTestResult | null> | null>(null)
|
||||
const [cachedDmSessionResults, setCachedDmSessionResults] = useState<Record<string, PlatformTestResult | null> | null>(null)
|
||||
const selectedAgentOpenedAtRef = useRef(0)
|
||||
const tokenRankOpenedAtRef = useRef(0)
|
||||
const modelPanelOpenedAtRef = useRef(0)
|
||||
@@ -742,7 +757,16 @@ export default function PixelOfficePage() {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
const map = new Map<string, { sessionCount: number; messageCount: number; totalTokens: number; todayAvgResponseMs: number; weeklyResponseMs: number[]; weeklyTokens: number[]; lastActive: number | null }>()
|
||||
const configMap = new Map<string, ConfigAgentCard>()
|
||||
for (const agent of (data.agents || [])) {
|
||||
configMap.set(agent.id, {
|
||||
id: agent.id,
|
||||
name: agent.name || agent.id,
|
||||
emoji: agent.emoji || '🤖',
|
||||
model: agent.model || '',
|
||||
platforms: Array.isArray(agent.platforms) ? agent.platforms : [],
|
||||
session: agent.session || undefined,
|
||||
})
|
||||
if (agent.session) {
|
||||
map.set(agent.id, {
|
||||
sessionCount: agent.session.sessionCount || 0,
|
||||
@@ -756,8 +780,18 @@ export default function PixelOfficePage() {
|
||||
}
|
||||
}
|
||||
agentStatsRef.current = map
|
||||
configAgentsRef.current = configMap
|
||||
if (data.gateway) gatewayRef.current = { port: data.gateway.port || 18789, token: data.gateway.token, host: data.gateway.host }
|
||||
if (data.providers) providersRef.current = data.providers
|
||||
if (data.providers) {
|
||||
providersRef.current = data.providers
|
||||
const accessModeMap: Record<string, 'auth' | 'api_key'> = {}
|
||||
for (const provider of data.providers) {
|
||||
if (provider?.id && (provider.accessMode === 'auth' || provider.accessMode === 'api_key')) {
|
||||
accessModeMap[provider.id] = provider.accessMode
|
||||
}
|
||||
}
|
||||
providerAccessModeRef.current = accessModeMap
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
fetchStats()
|
||||
@@ -772,6 +806,34 @@ export default function PixelOfficePage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshGatewayHealthSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentId) return
|
||||
try {
|
||||
const modelRaw = localStorage.getItem('agentTestResults')
|
||||
setCachedModelTestResults(modelRaw ? JSON.parse(modelRaw) : null)
|
||||
} catch {
|
||||
setCachedModelTestResults(null)
|
||||
}
|
||||
try {
|
||||
const platformRaw = localStorage.getItem('platformTestResults')
|
||||
setCachedPlatformTestResults(platformRaw ? JSON.parse(platformRaw) : null)
|
||||
} catch {
|
||||
setCachedPlatformTestResults(null)
|
||||
}
|
||||
try {
|
||||
const sessionRaw = localStorage.getItem('sessionTestResults')
|
||||
setCachedSessionTestResults(sessionRaw ? JSON.parse(sessionRaw) : null)
|
||||
} catch {
|
||||
setCachedSessionTestResults(null)
|
||||
}
|
||||
try {
|
||||
const dmRaw = localStorage.getItem('dmSessionResults')
|
||||
setCachedDmSessionResults(dmRaw ? JSON.parse(dmRaw) : null)
|
||||
} catch {
|
||||
setCachedDmSessionResults(null)
|
||||
}
|
||||
}, [selectedAgentId])
|
||||
|
||||
// Keep gateway SRE head label aligned with current locale.
|
||||
useEffect(() => {
|
||||
if (!officeReady) return
|
||||
@@ -1786,13 +1848,34 @@ export default function PixelOfficePage() {
|
||||
|
||||
{/* Agent detail card (click) */}
|
||||
{selectedAgentId && !isEditMode && (() => {
|
||||
const agent = agents.find(a => a.agentId === selectedAgentId)
|
||||
const stats = agentStatsRef.current.get(selectedAgentId)
|
||||
if (!agent) return null
|
||||
const responseColor = stats?.todayAvgResponseMs
|
||||
? stats.todayAvgResponseMs > 50000 ? 'text-red-400'
|
||||
: stats.todayAvgResponseMs > 30000 ? 'text-yellow-400'
|
||||
: 'text-green-400' : 'text-[var(--text-muted)]'
|
||||
const runtimeAgent = agents.find(a => a.agentId === selectedAgentId)
|
||||
const configAgent = configAgentsRef.current.get(selectedAgentId)
|
||||
const stats = agentStatsRef.current.get(selectedAgentId) ?? configAgent?.session
|
||||
const displayState = runtimeAgent?.state || 'offline'
|
||||
const gw = gatewayRef.current
|
||||
|
||||
if (!runtimeAgent && !configAgent) return null
|
||||
|
||||
const cardAgent: AgentCardAgent = {
|
||||
id: selectedAgentId,
|
||||
name: configAgent?.name || runtimeAgent?.name || selectedAgentId,
|
||||
emoji: configAgent?.emoji || runtimeAgent?.emoji || '🤖',
|
||||
model: configAgent?.model || '',
|
||||
platforms: configAgent?.platforms || [],
|
||||
session: stats
|
||||
? {
|
||||
lastActive: stats.lastActive,
|
||||
totalTokens: stats.totalTokens,
|
||||
contextTokens: 0,
|
||||
sessionCount: stats.sessionCount,
|
||||
todayAvgResponseMs: stats.todayAvgResponseMs,
|
||||
messageCount: stats.messageCount,
|
||||
weeklyResponseMs: stats.weeklyResponseMs,
|
||||
weeklyTokens: stats.weeklyTokens,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={modalOverlayClass}
|
||||
@@ -1801,27 +1884,23 @@ export default function PixelOfficePage() {
|
||||
setSelectedAgentId(null)
|
||||
}}
|
||||
>
|
||||
<div className={modalPanelClass("w-72", "max-h-[78%]")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{agent.emoji}</span>
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text)]">{agent.name}</div>
|
||||
<span className={`text-[10px] uppercase tracking-wider ${
|
||||
agent.state === 'working' ? 'text-green-400' :
|
||||
agent.state === 'idle' ? 'text-yellow-400' : 'text-slate-400'
|
||||
}`}>{t(`pixelOffice.state.${agent.state}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={modalPanelClass("w-[24rem]", "max-h-[78%]")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => setSelectedAgentId(null)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">{t('agent.sessionCount')}</span><span className="text-[var(--text)]">{stats?.sessionCount ?? '--'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">{t('agent.messageCount')}</span><span className="text-[var(--text)]">{stats?.messageCount ?? '--'}</span></div>
|
||||
<div className="flex justify-between items-center"><span className="text-[var(--text-muted)]">{t('agent.tokenUsage')}</span><div className="flex items-center gap-2">{stats?.weeklyTokens && <MiniSparkline data={stats.weeklyTokens} color="#4ade80" />}<span className="text-[var(--text)]">{stats ? formatTokens(stats.totalTokens) : '--'}</span></div></div>
|
||||
<div className="flex justify-between items-center"><span className="text-[var(--text-muted)]">{t('agent.todayAvgResponse')}</span><div className="flex items-center gap-2">{stats?.weeklyResponseMs && <MiniSparkline data={stats.weeklyResponseMs} />}<span className={responseColor}>{stats?.todayAvgResponseMs ? formatMs(stats.todayAvgResponseMs) : '--'}</span></div></div>
|
||||
{stats?.lastActive && <div className="flex justify-between"><span className="text-[var(--text-muted)]">{t('agent.lastActive')}</span><span className="text-[var(--text)]">{new Date(stats.lastActive).toLocaleString('zh-CN')}</span></div>}
|
||||
</div>
|
||||
<AgentCard
|
||||
agent={cardAgent}
|
||||
gatewayPort={gw.port}
|
||||
gatewayToken={gw.token}
|
||||
gatewayHost={gw.host}
|
||||
t={t}
|
||||
testResult={cachedModelTestResults?.[selectedAgentId]}
|
||||
platformTestResults={cachedPlatformTestResults || undefined}
|
||||
sessionTestResult={cachedSessionTestResults?.[selectedAgentId]}
|
||||
agentState={displayState}
|
||||
dmSessionResults={cachedDmSessionResults || undefined}
|
||||
providerAccessModeMap={providerAccessModeRef.current}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const DEFAULT_MODEL_PROBE_TIMEOUT_MS = 15000;
|
||||
|
||||
type ProviderApiType = "anthropic-messages" | "openai-completions" | string;
|
||||
|
||||
interface ProviderConfig {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
api?: ProviderApiType;
|
||||
authHeader?: boolean | string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProbeResult {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: "api_key" | "oauth" | string;
|
||||
status?: "ok" | "error" | "unknown" | string;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
interface DirectProbeResult {
|
||||
ok: boolean;
|
||||
elapsed: number;
|
||||
status: string;
|
||||
error?: string;
|
||||
mode: "api_key";
|
||||
source: "direct_model_probe";
|
||||
precision: "model";
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ModelProbeOutcome {
|
||||
ok: boolean;
|
||||
elapsed: number;
|
||||
model: string;
|
||||
mode: "api_key" | "oauth" | "unknown" | string;
|
||||
status: string;
|
||||
error?: string;
|
||||
text?: string;
|
||||
source: "direct_model_probe" | "openclaw_provider_probe";
|
||||
precision: "model" | "provider";
|
||||
}
|
||||
|
||||
interface ProbeModelParams {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
const MODELS_PATH = path.join(OPENCLAW_HOME, "agents", "main", "agent", "models.json");
|
||||
|
||||
function parseJsonFromMixedOutput(output: string): any {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
if (output[i] !== "{") continue;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let j = i; j < output.length; j++) {
|
||||
const ch = output[j];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === "\\") escaped = true;
|
||||
else if (ch === "\"") inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = output.slice(i, j + 1).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to parse JSON output from openclaw models status --probe --json");
|
||||
}
|
||||
|
||||
function loadProviderConfig(providerId: string): ProviderConfig | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(MODELS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const providers = parsed?.providers;
|
||||
if (!providers || typeof providers !== "object") return null;
|
||||
const exact = providers[providerId];
|
||||
if (exact && typeof exact === "object") return exact as ProviderConfig;
|
||||
const normalizedTarget = providerId.toLowerCase();
|
||||
for (const [key, value] of Object.entries(providers)) {
|
||||
if (key.toLowerCase() === normalizedTarget && value && typeof value === "object") {
|
||||
return value as ProviderConfig;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickAuthHeader(providerCfg: ProviderConfig, apiKey: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const authHeader = providerCfg.authHeader;
|
||||
const api = providerCfg.api;
|
||||
|
||||
if (typeof authHeader === "string" && authHeader.trim()) {
|
||||
out[authHeader.trim()] = apiKey;
|
||||
return out;
|
||||
}
|
||||
|
||||
if (authHeader === false) {
|
||||
out["x-api-key"] = apiKey;
|
||||
return out;
|
||||
}
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
out["x-api-key"] = apiKey;
|
||||
out["Authorization"] = `Bearer ${apiKey}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
out["Authorization"] = `Bearer ${apiKey}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyErrorStatus(httpStatus: number, errorText: string): string {
|
||||
const normalized = errorText.toLowerCase();
|
||||
if (normalized.includes("timed out")) return "timeout";
|
||||
if (normalized.includes("model_not_supported")) return "model_not_supported";
|
||||
if (httpStatus === 401 || httpStatus === 403 || normalized.includes("unauthorized")) return "auth";
|
||||
if (httpStatus === 429 || normalized.includes("rate limit")) return "rate_limit";
|
||||
if (httpStatus === 402 || normalized.includes("billing")) return "billing";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: any, fallback: string): string {
|
||||
const direct = payload?.error?.message || payload?.message || payload?.error;
|
||||
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeResult | null> {
|
||||
const providerCfg = loadProviderConfig(params.providerId);
|
||||
if (!providerCfg?.baseUrl || !providerCfg.api || !providerCfg.apiKey) return null;
|
||||
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS;
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...(providerCfg.headers || {}),
|
||||
...pickAuthHeader(providerCfg, providerCfg.apiKey),
|
||||
};
|
||||
|
||||
if (providerCfg.api === "anthropic-messages") {
|
||||
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
|
||||
const url = `${providerCfg.baseUrl.replace(/\/+$/, "")}/v1/messages`;
|
||||
const body = {
|
||||
model: params.modelId,
|
||||
max_tokens: 8,
|
||||
messages: [{ role: "user", content: "Reply with OK." }],
|
||||
};
|
||||
const start = Date.now();
|
||||
try {
|
||||
const resp = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
|
||||
const elapsed = Date.now() - start;
|
||||
if (resp.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
elapsed,
|
||||
status: "ok",
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
text: "OK (direct model probe)",
|
||||
};
|
||||
}
|
||||
let payload: any = null;
|
||||
try { payload = await resp.json(); } catch {}
|
||||
const error = extractErrorMessage(payload, `HTTP ${resp.status}`);
|
||||
return {
|
||||
ok: false,
|
||||
elapsed,
|
||||
status: classifyErrorStatus(resp.status, error),
|
||||
error,
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
};
|
||||
} catch (err: any) {
|
||||
const elapsed = Date.now() - start;
|
||||
const isTimeout = err?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
elapsed,
|
||||
status: isTimeout ? "timeout" : "network",
|
||||
error: isTimeout ? "LLM request timed out." : (err?.message || "Network error"),
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (providerCfg.api === "openai-completions") {
|
||||
const url = `${providerCfg.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const body = {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "Reply with OK." }],
|
||||
max_tokens: 8,
|
||||
temperature: 0,
|
||||
};
|
||||
const start = Date.now();
|
||||
try {
|
||||
const resp = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
|
||||
const elapsed = Date.now() - start;
|
||||
if (resp.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
elapsed,
|
||||
status: "ok",
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
text: "OK (direct model probe)",
|
||||
};
|
||||
}
|
||||
let payload: any = null;
|
||||
try { payload = await resp.json(); } catch {}
|
||||
const error = extractErrorMessage(payload, `HTTP ${resp.status}`);
|
||||
return {
|
||||
ok: false,
|
||||
elapsed,
|
||||
status: classifyErrorStatus(resp.status, error),
|
||||
error,
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
};
|
||||
} catch (err: any) {
|
||||
const elapsed = Date.now() - start;
|
||||
const isTimeout = err?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
elapsed,
|
||||
status: isTimeout ? "timeout" : "network",
|
||||
error: isTimeout ? "LLM request timed out." : (err?.message || "Network error"),
|
||||
mode: "api_key",
|
||||
source: "direct_model_probe",
|
||||
precision: "model",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function probeProviderViaOpenclaw(params: ProbeModelParams): Promise<ModelProbeOutcome> {
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
"openclaw",
|
||||
[
|
||||
"models",
|
||||
"status",
|
||||
"--probe",
|
||||
"--json",
|
||||
"--probe-timeout",
|
||||
String(timeoutMs),
|
||||
"--probe-provider",
|
||||
String(params.providerId),
|
||||
],
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
}
|
||||
);
|
||||
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
|
||||
const results: ProbeResult[] = parsed?.auth?.probes?.results || [];
|
||||
const fullModel = `${params.providerId}/${params.modelId}`;
|
||||
|
||||
const exact =
|
||||
results.find((r) => r.provider === params.providerId && r.model === fullModel) ||
|
||||
results.find((r) => r.provider === params.providerId && typeof r.model === "string" && r.model.endsWith(`/${params.modelId}`));
|
||||
const matched = exact || results.find((r) => r.provider === params.providerId);
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
ok: false,
|
||||
elapsed: Date.now() - startedAt,
|
||||
model: fullModel,
|
||||
mode: "unknown",
|
||||
status: "unknown",
|
||||
error: `No probe result for provider ${params.providerId}`,
|
||||
precision: "provider",
|
||||
source: "openclaw_provider_probe",
|
||||
};
|
||||
}
|
||||
|
||||
const ok = matched.status === "ok";
|
||||
return {
|
||||
ok,
|
||||
elapsed: matched.latencyMs ?? (Date.now() - startedAt),
|
||||
model: matched.model || fullModel,
|
||||
mode: matched.mode || "unknown",
|
||||
status: matched.status || "unknown",
|
||||
error: ok ? undefined : (matched.error || `Probe status: ${matched.status || "unknown"}`),
|
||||
precision: exact ? "model" : "provider",
|
||||
source: "openclaw_provider_probe",
|
||||
text: ok ? `OK (${exact ? "model-level" : "provider-level"} openclaw probe)` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseModelRef(modelStr: string): { providerId: string; modelId: string } {
|
||||
const [providerId, ...rest] = modelStr.split("/");
|
||||
return { providerId: providerId || "", modelId: rest.join("/") || providerId || "" };
|
||||
}
|
||||
|
||||
export async function probeModel(params: ProbeModelParams): Promise<ModelProbeOutcome> {
|
||||
const direct = await probeModelDirect(params);
|
||||
if (direct) {
|
||||
return {
|
||||
...direct,
|
||||
model: `${params.providerId}/${params.modelId}`,
|
||||
};
|
||||
}
|
||||
return probeProviderViaOpenclaw(params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user