diff --git a/app/api/test-agents/route.ts b/app/api/test-agents/route.ts index b2948b1..d926200 100644 --- a/app/api/test-agents/route.ts +++ b/app/api/test-agents/route.ts @@ -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>>>(); - 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>>(); + 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(); } + diff --git a/app/api/test-model/route.ts b/app/api/test-model/route.ts index 2438d5d..2d2987e 100644 --- a/app/api/test-model/route.ts +++ b/app/api/test-model/route.ts @@ -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) { ); } } + diff --git a/app/components/agent-card.tsx b/app/components/agent-card.tsx new file mode 100644 index 0000000..f52441b --- /dev/null +++ b/app/components/agent-card.tsx @@ -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 { + 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) => { + e.preventDefault(); + e.stopPropagation(); + const ok = await copyText(errorText); + setCopyState(ok ? "copied" : "failed"); + }; + + return ( + setCopyState("idle")} + > + + + ); +} + +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 ( + + ); +} + +export function ModelBadge({ model, accessMode }: { model: string; accessMode?: "auth" | "api_key" }) { + const [provider, modelName] = model.includes("/") + ? model.split("/", 2) + : ["default", model]; + + const colors: Record = { + "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 ( + + {modelName}{accessMode ? ` (${accessMode})` : ""} + + ); +} + +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 ( + + v ? formatMs(v) : "-").join(" → ")}> + + + + + + + + + {pts.filter((p) => p.v > 0).map((p, i) => ( + + ))} + + + ); +} + +function AgentStatusBadge({ state, t }: { state?: string; t: TFunc }) { + const config: Record = { + 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 ( + + + {c.text} + + ); +} + +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; + sessionTestResult?: AgentSessionTestResult | null; + agentState?: string; + dmSessionResults?: Record; + providerAccessModeMap?: Record; +}) { + 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 ( +
+
+ {agent.emoji} +
+

{agent.name}

+
+ +
+ +
+ +
+ {t("agent.model")} +
+ + {testResult === undefined ? ( + -- + ) : testResult === null ? ( + + ) : testResult.ok ? ( + + ) : ( + + )} +
+
+ +
+ {t("agent.platform")} +
+ {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 ( +
+ +
+ {dmResult === undefined ? ( + DM Session: -- + ) : dmResult === null ? ( + DM Session: ⏳ + ) : dmResult.ok ? ( + DM Session: ✅ + ) : ( + + DM Session: + + + )} +
+
+ ); + })} +
+
+ + {agent.session && ( +
+ +
+ {t("agent.messageCount")} + {agent.session.messageCount} +
+
+ {t("agent.tokenUsage")} + {agent.session.weeklyTokens && } + {formatTokens(agent.session.totalTokens)} +
+ {agent.session.lastActive && ( +
+ {t("agent.lastActive")} + {formatTimeAgo(agent.session.lastActive)} +
+ )} +
+ {t("agent.todayAvgResponse")} + {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 ( + + {val ? formatMs(val) : "--"}{arrow && {arrow}} + + ); + })()} +
+
+ )} +
+
+ ); +} diff --git a/app/models/page.tsx b/app/models/page.tsx index 241729b..c3dce4a 100644 --- a/app/models/page.tsx +++ b/app/models/page.tsx @@ -91,61 +91,50 @@ export default function ModelsPage() { const testAllModels = async () => { if (!data) return; - const providerModels: Record = {}; + const modelTargets: Array<{ providerId: string; modelId: string; key: string }> = []; + const seen = new Set(); + 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 })); + } + }) ); }; diff --git a/app/page.tsx b/app/page.tsx index 0fa2366..acb5193 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 { - 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) => { - e.preventDefault(); - e.stopPropagation(); - const ok = await copyText(errorText); - setCopyState(ok ? "copied" : "failed"); - }; - - return ( - setCopyState("idle")} - > - - - ); -} - // 趋势折线图 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
{t("common.noData")}
; @@ -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 ( - - ); -} - -// 模型标签 -function ModelBadge({ model, accessMode }: { model: string; accessMode?: "auth" | "api_key" }) { - const [provider, modelName] = model.includes("/") - ? model.split("/", 2) - : ["default", model]; - - const colors: Record = { - "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 ( - - {modelName}{accessMode ? ` (${accessMode})` : ""} - - ); -} - -// 迷你曲线图 (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 ( - - v ? formatMs(v) : '-').join(' → ')}> - - - - - - - - - {pts.filter(p => p.v > 0).map((p, i) => ( - - ))} - - - ); -} - -// Agent 状态标签 -function AgentStatusBadge({ state, t }: { state?: string; t: TFunc }) { - const config: Record = { - 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 ( - - - {c.text} - - ); -} - -// 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; sessionTestResult?: { ok: boolean; reply?: string; error?: string; elapsed: number } | null; agentState?: string; dmSessionResults?: Record; providerAccessModeMap?: Record }) { - 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 ( -
-
- {agent.emoji} -
-

{agent.name}

-
- -
- -
- -
- {t("agent.model")} -
- - {testResult === undefined ? ( - -- - ) : testResult === null ? ( - - ) : testResult.ok ? ( - - ) : ( - - )} -
-
- -
- {t("agent.platform")} -
- {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 ( -
- -
- {dmResult === undefined ? ( - DM Session: -- - ) : dmResult === null ? ( - DM Session: ⏳ - ) : dmResult.ok ? ( - DM Session: ✅ - ) : ( - - DM Session: - - - )} -
-
- ); - })} -
-
- - {agent.session && ( -
- -
- {t("agent.messageCount")} - {agent.session.messageCount} -
-
- {t("agent.tokenUsage")} - {agent.session.weeklyTokens && } - {formatTokens(agent.session.totalTokens)} -
- {agent.session.lastActive && ( -
- {t("agent.lastActive")} - {formatTimeAgo(agent.session.lastActive)} -
- )} -
- {t("agent.todayAvgResponse")} - {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 ( - - {val ? formatMs(val) : "--"}{arrow && {arrow}} - - ); - })()} -
-
- )} - - {/* test result moved inline next to model badge */} -
-
- ); -} - export default function Home() { const { t } = useI18n(); const [data, setData] = useState(cachedHomeData); @@ -601,11 +189,11 @@ export default function Home() { const [allStats, setAllStats] = useState(cachedHomeAllStats); const [statsRange, setStatsRange] = useState("daily"); const timerRef = useRef(null); - const [testResults, setTestResults] = useState | null>(null); + const [testResults, setTestResults] = useState | null>(null); const [testing, setTesting] = useState(false); const [platformTestResults, setPlatformTestResults] = useState | null>(null); const [testingPlatforms, setTestingPlatforms] = useState(false); - const [sessionTestResults, setSessionTestResults] = useState | null>(null); + const [sessionTestResults, setSessionTestResults] = useState | null>(null); const [testingSessions, setTestingSessions] = useState(false); const [dmSessionResults, setDmSessionResults] = useState | null>(null); const [testingDmSessions, setTestingDmSessions] = useState(false); diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 0534625..813e72f 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -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(null) const mousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) const agentStatsRef = useRef>(new Map()) + const configAgentsRef = useRef>(new Map()) const contributionsRef = useRef(null) const photographRef = useRef(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>({}) const providersRef = useRef; 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 | null>(null) + const [cachedPlatformTestResults, setCachedPlatformTestResults] = useState | null>(null) + const [cachedSessionTestResults, setCachedSessionTestResults] = useState | null>(null) + const [cachedDmSessionResults, setCachedDmSessionResults] = useState | 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() + const configMap = new Map() 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 = {} + 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 (
-
e.stopPropagation()}> -
-
- {agent.emoji} -
-
{agent.name}
- {t(`pixelOffice.state.${agent.state}`)} -
-
+
e.stopPropagation()}> +
-
-
{t('agent.sessionCount')}{stats?.sessionCount ?? '--'}
-
{t('agent.messageCount')}{stats?.messageCount ?? '--'}
-
{t('agent.tokenUsage')}
{stats?.weeklyTokens && }{stats ? formatTokens(stats.totalTokens) : '--'}
-
{t('agent.todayAvgResponse')}
{stats?.weeklyResponseMs && }{stats?.todayAvgResponseMs ? formatMs(stats.todayAvgResponseMs) : '--'}
- {stats?.lastActive &&
{t('agent.lastActive')}{new Date(stats.lastActive).toLocaleString('zh-CN')}
} -
+
) diff --git a/lib/model-probe.ts b/lib/model-probe.ts new file mode 100644 index 0000000..c36ccee --- /dev/null +++ b/lib/model-probe.ts @@ -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; +} + +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 { + const out: Record = {}; + 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 { + 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 { + 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 = { + "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 { + 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 { + const direct = await probeModelDirect(params); + if (direct) { + return { + ...direct, + model: `${params.providerId}/${params.modelId}`, + }; + } + return probeProviderViaOpenclaw(params); +} +