refactor agent card and improve model probe flow

This commit is contained in:
xmanrui
2026-03-05 04:32:33 +08:00
parent ca9dd9bf31
commit 430043952d
7 changed files with 1084 additions and 670 deletions
+54 -85
View File
@@ -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
View File
@@ -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) {
);
}
}
+509
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
+352
View File
@@ -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);
}