Merge remote-tracking branch 'origin/main'

This commit is contained in:
danlee
2026-03-11 22:40:35 +08:00
33 changed files with 894 additions and 258 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
// Server-side cache: 5 min TTL
let cache: { data: any; ts: number } | null = null;
+31 -7
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'
import { promises as fs, existsSync } from 'fs'
import path from 'path'
import os from 'os'
import { parseJsonText } from '@/lib/json'
import { OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH } from '@/lib/openclaw-paths'
export const dynamic = 'force-dynamic'
export const revalidate = 0
@@ -39,6 +40,30 @@ export interface AgentActivity {
subagents?: SubagentInfo[]
}
type AgentConfigEntry = {
id: string
name?: string
emoji?: string
identity?: { emoji?: string }
}
async function loadAgentList(config: any, agentsDir: string): Promise<AgentConfigEntry[]> {
const configured = Array.isArray(config?.agents?.list)
? config.agents.list.filter((agent: any) => agent && typeof agent.id === 'string' && agent.id)
: []
if (configured.length > 0) return configured
try {
if (!existsSync(agentsDir)) return []
const dirs = await fs.readdir(agentsDir, { withFileTypes: true })
return dirs
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => ({ id: entry.name }))
} catch {
return []
}
}
function isSubtaskDescription(desc: string): boolean {
const d = desc.toLowerCase()
return desc.startsWith('Subtask:') || desc.startsWith('子任务') || d.includes('subtask')
@@ -483,19 +508,18 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis
}
export async function GET() {
const openclawDir = path.join(os.homedir(), '.openclaw')
const configPath = path.join(openclawDir, 'openclaw.json')
const agentsDir = path.join(openclawDir, 'agents')
const configPath = OPENCLAW_CONFIG_PATH
const agentsDir = OPENCLAW_AGENTS_DIR
const agents: AgentActivity[] = []
try {
if (existsSync(configPath)) {
const configContent = await fs.readFile(configPath, 'utf8')
const config = JSON.parse(configContent)
const config = parseJsonText(configContent)
const agentList = Array.isArray(config.agents) ? config.agents : config.agents?.list
if (agentList && Array.isArray(agentList)) {
const agentList = await loadAgentList(config, agentsDir)
if (agentList.length > 0) {
const now = Date.now()
for (const agent of agentList) {
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
// 状态: working(2分钟内有assistant消息) / online(10分钟内) / idle(24小时内) / offline(超过24小时)
type AgentState = "working" | "online" | "idle" | "offline";
+3 -4
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
const ALERTS_CONFIG_PATH = path.join(OPENCLAW_HOME, "alerts.json");
interface AlertRule {
@@ -37,7 +36,7 @@ function getAlertConfig(): AlertConfig {
}
function getOpenclawConfig() {
const configPath = path.join(OPENCLAW_HOME, "openclaw.json");
const configPath = OPENCLAW_CONFIG_PATH;
try {
const raw = fs.readFileSync(configPath, "utf-8");
return JSON.parse(raw);
@@ -55,7 +54,7 @@ function saveAlertConfig(config: AlertConfig): void {
}
function getGatewayConfig() {
const configPath = path.join(OPENCLAW_HOME, "openclaw.json");
const configPath = OPENCLAW_CONFIG_PATH;
try {
const raw = fs.readFileSync(configPath, "utf-8");
const config = JSON.parse(raw);
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
const ALERTS_CONFIG_PATH = path.join(OPENCLAW_HOME, "alerts.json");
interface AlertRule {
+2 -2
View File
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
// 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
const OPENCLAW_DIR = OPENCLAW_HOME;
// 30秒内存缓存
+90 -27
View File
@@ -1,15 +1,39 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { execFile } from "child_process";
import { exec, execFile } from "child_process";
import { promisify } from "util";
import { readJsonFileSync } from "@/lib/json";
import { OPENCLAW_CONFIG_PATH } from "@/lib/openclaw-paths";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
const DEGRADED_LATENCY_MS = 1500;
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
let cachedOpenclawVersion: { value: string | null; expiresAt: number } | null = null;
function quoteShellArg(arg: string): string {
if (/^[A-Za-z0-9_./:=@-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '""')}"`;
}
async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
}
const command = `openclaw ${args.map(quoteShellArg).join(" ")}`;
return execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
env,
shell: "cmd.exe",
});
}
function parseJsonFromMixedOutput(output: string): any {
for (let i = 0; i < output.length; i++) {
if (output[i] !== "{") continue;
@@ -49,10 +73,7 @@ async function probeGatewayViaCli(token: string, timeoutMs = 5000): Promise<{ ok
try {
const args = ["gateway", "status", "--json", "--timeout", String(timeoutMs)];
if (token) args.push("--token", token);
const { stdout, stderr } = await execFileAsync("openclaw", args, {
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
});
const { stdout, stderr } = await execOpenclaw(args);
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
const ok = parsed?.rpc?.ok === true;
const error =
@@ -66,16 +87,31 @@ async function probeGatewayViaCli(token: string, timeoutMs = 5000): Promise<{ ok
}
}
async function probeGatewayViaWeb(port: number, token: string, timeoutMs = 5000): Promise<{ ok: boolean; error?: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(
`http://localhost:${port}/chat${token ? `?token=${encodeURIComponent(token)}` : ""}`,
{ signal: controller.signal, cache: "no-store", redirect: "manual" },
);
return resp.status >= 200 && resp.status < 400
? { ok: true }
: { ok: false, error: `HTTP ${resp.status}` };
} catch (err: any) {
return { ok: false, error: err?.message || "Failed to probe gateway web UI" };
} finally {
clearTimeout(timeout);
}
}
async function getOpenclawVersion(): Promise<string | undefined> {
const now = Date.now();
if (cachedOpenclawVersion && cachedOpenclawVersion.expiresAt > now) {
return cachedOpenclawVersion.value || undefined;
}
try {
const { stdout } = await execFileAsync("openclaw", ["--version"], {
maxBuffer: 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
});
const { stdout } = await execOpenclaw(["--version"]);
const version = stdout.trim().split(/\s+/)[0] || null;
cachedOpenclawVersion = { value: version, expiresAt: now + 60 * 60 * 1000 };
return version || undefined;
@@ -89,10 +125,10 @@ export async function GET() {
const startedAt = Date.now();
try {
const openclawVersion = await getOpenclawVersion();
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw);
const config = readJsonFileSync<any>(CONFIG_PATH);
const port = config.gateway?.port || 18789;
const token = config.gateway?.auth?.token || "";
const webUrl = `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`;
const url = `http://localhost:${port}/api/health`;
const headers: Record<string, string> = {};
@@ -114,7 +150,22 @@ export async function GET() {
status: responseMs > DEGRADED_LATENCY_MS ? "degraded" : "healthy",
checkedAt,
responseMs,
webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`,
webUrl,
});
}
const web = await probeGatewayViaWeb(port, token, 5000);
if (web.ok) {
const checkedAt = Date.now();
const responseMs = checkedAt - startedAt;
return NextResponse.json({
ok: true,
data: null,
openclawVersion,
status: resp.status === 404 ? "healthy" : "degraded",
checkedAt,
responseMs,
webUrl,
});
}
@@ -130,7 +181,7 @@ export async function GET() {
status: "healthy",
checkedAt,
responseMs,
webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`,
webUrl,
});
}
@@ -152,26 +203,38 @@ export async function GET() {
: err.message;
const token = (() => {
try {
const rawCfg = fs.readFileSync(CONFIG_PATH, "utf-8");
const cfg = JSON.parse(rawCfg);
const cfg = readJsonFileSync<any>(CONFIG_PATH);
return cfg.gateway?.auth?.token || "";
} catch {
return "";
}
})();
const port = (() => {
try {
const cfg = readJsonFileSync<any>(CONFIG_PATH);
return cfg.gateway?.port || 18789;
} catch {
return 18789;
}
})();
const web = await probeGatewayViaWeb(port, token, 5000);
if (web.ok) {
const checkedAt = Date.now();
const responseMs = checkedAt - startedAt;
return NextResponse.json({
ok: true,
data: null,
openclawVersion,
status: "degraded",
checkedAt,
responseMs,
webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`,
});
}
const cli = await probeGatewayViaCli(token, 5000);
const checkedAt = Date.now();
const responseMs = checkedAt - startedAt;
if (cli.ok) {
const port = (() => {
try {
const rawCfg = fs.readFileSync(CONFIG_PATH, "utf-8");
const cfg = JSON.parse(rawCfg);
return cfg.gateway?.port || 18789;
} catch {
return 18789;
}
})();
return NextResponse.json({
ok: true,
data: null,
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
const ACTIVE_GAP_MS = 2 * 60 * 1000; // 2 minutes — gaps longer than this count as idle
// Server-side cache: 5 min TTL
+2 -2
View File
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { OPENCLAW_PIXEL_OFFICE_DIR } from '@/lib/openclaw-paths'
const LAYOUT_DIR = path.join(os.homedir(), '.openclaw', 'pixel-office')
const LAYOUT_DIR = OPENCLAW_PIXEL_OFFICE_DIR
const LAYOUT_FILE = path.join(LAYOUT_DIR, 'layout.json')
export async function GET() {
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
export async function GET(_req: Request, { params }: { params: Promise<{ agentId: string }> }) {
try {
+3 -8
View File
@@ -1,17 +1,12 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
// Find OpenClaw package directory
function findOpenClawPkg(): string {
// Check common locations
const candidates = [
path.join(process.env.HOME || "", ".nvm/versions/node", process.version, "lib/node_modules/openclaw"),
"/usr/local/lib/node_modules/openclaw",
"/usr/lib/node_modules/openclaw",
];
const candidates = getOpenclawPackageCandidates();
for (const c of candidates) {
if (fs.existsSync(path.join(c, "package.json"))) return c;
}
@@ -148,7 +143,7 @@ export async function GET() {
}
// 5. Get agent info for display
const configPath = path.join(OPENCLAW_HOME, "openclaw.json");
const configPath = OPENCLAW_CONFIG_PATH;
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const agentList = config.agents?.list || [];
const agentMap: Record<string, { name: string; emoji: string }> = {};
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
// 30秒内存缓存
let statsCache: { data: any; ts: number } | null = null;
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
// 30秒内存缓存
let statsCache: { data: any; ts: number } | null = null;
+1 -2
View File
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
interface DayStat {
date: string; // YYYY-MM-DD
+2 -3
View File
@@ -2,9 +2,9 @@ import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { DEFAULT_MODEL_PROBE_TIMEOUT_MS, parseModelRef, probeModel } from "@/lib/model-probe";
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
const PROBE_TIMEOUT_MS = DEFAULT_MODEL_PROBE_TIMEOUT_MS;
type AgentConfig = {
@@ -98,4 +98,3 @@ export async function POST() {
export async function GET() {
return POST();
}
+12 -5
View File
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback";
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
interface DmSessionResult {
agentId: string;
@@ -65,11 +65,18 @@ async function testDmSession(
signal: AbortSignal.timeout(100000),
});
const data = await resp.json();
const rawText = await resp.text();
const data = parseApiJsonSafely(rawText);
const elapsed = Date.now() - startTime;
if (!resp.ok) {
return { agentId, platform, ok: false, error: data.error?.message || JSON.stringify(data), elapsed };
if (shouldFallbackToCli(resp, rawText)) {
const fallback = await testSessionViaCli(agentId);
return fallback.ok
? { agentId, platform, ok: true, detail: `${fallback.reply || "OK"} · DM fallback`, elapsed: fallback.elapsed }
: { agentId, platform, ok: false, error: fallback.error || "Gateway route not found", elapsed: fallback.elapsed };
}
return { agentId, platform, ok: false, error: data?.error?.message || rawText || JSON.stringify(data), elapsed };
}
const reply = data.choices?.[0]?.message?.content || "";
+8 -58
View File
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
const QQBOT_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
const QQBOT_API_BASE = "https://api.sgroup.qq.com";
@@ -641,53 +640,6 @@ async function testQqbot(
}
}
// Agent session test: use Gateway chatCompletions API to send health check
// When sessionKey is provided, message routes to that session (e.g. feishu DM session)
async function testAgentSession(agentId: string, sessionKey: string | undefined, gatewayPort: number, gatewayToken: string): Promise<{ agentId: string; ok: boolean; reply?: string; error?: string; elapsed: number }> {
const startTime = Date.now();
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Authorization": `Bearer ${gatewayToken}`,
"x-openclaw-agent-id": agentId,
};
if (sessionKey) {
headers["x-openclaw-session-key"] = sessionKey;
}
const resp = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: `openclaw:${agentId}`,
messages: [{ role: "user", content: "Health check: reply with OK" }],
max_tokens: 64,
}),
signal: AbortSignal.timeout(100000),
});
const data = await resp.json();
const elapsed = Date.now() - startTime;
if (!resp.ok) {
return { agentId, ok: false, error: data.error?.message || JSON.stringify(data), elapsed };
}
const reply = data.choices?.[0]?.message?.content || "";
return {
agentId, ok: true,
reply: reply.slice(0, 200) || "(no reply)",
elapsed,
};
} catch (err: any) {
return {
agentId, ok: false,
error: err.message,
elapsed: Date.now() - startTime,
};
}
}
export async function POST() {
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
@@ -725,12 +677,10 @@ export async function POST() {
// Phase 1: Platform API tests (parallel)
const platformTests: Promise<PlatformTestResult>[] = [];
const agentIds: string[] = [];
const testedFeishuAccounts = new Set<string>();
for (const agent of agentList) {
const id = agent.id;
agentIds.push(id);
// Feishu
const feishuBinding = bindings.find(
@@ -764,21 +714,21 @@ export async function POST() {
// WhatsApp: only test once, via gateway
if (id === "main" && whatsappConfig && whatsappConfig.enabled !== false) {
const sessionUser = getWhatsappDmUser(id);
const recentDmUser = getWhatsappDmUser(id);
const allowFromUser = getWhatsappAllowlistUser(whatsappConfig);
const whatsappTestUser = sessionUser || allowFromUser || null;
const whatsappTestUser = recentDmUser || allowFromUser || null;
const source: "session" | "allowFrom" | "none" =
sessionUser ? "session" : (allowFromUser ? "allowFrom" : "none");
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
platformTests.push(testWhatsapp(id, gatewayPort, gatewayToken, whatsappTestUser, source));
}
// QQBot: only test once, via `openclaw message send`
if (id === "main" && qqbotConfig && qqbotConfig.enabled !== false) {
const sessionUser = normalizeQqbotTarget(getQqbotDmUser(id));
const recentDmUser = normalizeQqbotTarget(getQqbotDmUser(id));
const allowFromUser = normalizeQqbotTarget(getQqbotAllowlistUser(qqbotConfig));
const qqbotTestUser = sessionUser || allowFromUser || null;
const qqbotTestUser = recentDmUser || allowFromUser || null;
const source: "session" | "allowFrom" | "none" =
sessionUser ? "session" : (allowFromUser ? "allowFrom" : "none");
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
platformTests.push(testQqbot(id, qqbotConfig, qqbotTestUser, source));
}
}
+16 -6
View File
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = path.join(process.env.HOME || "/root", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback";
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
export async function POST(req: Request) {
try {
@@ -38,16 +38,26 @@ export async function POST(req: Request) {
}),
signal: AbortSignal.timeout(100000),
});
const data = await resp.json();
const rawText = await resp.text();
const data = parseApiJsonSafely(rawText);
const elapsed = Date.now() - startTime;
if (!resp.ok) {
if (shouldFallbackToCli(resp, rawText)) {
const fallback = await testSessionViaCli(agentId);
return NextResponse.json({
status: fallback.ok ? "ok" : "error",
sessionKey,
elapsed: fallback.elapsed,
reply: fallback.reply,
error: fallback.error,
});
}
return NextResponse.json({
status: "error",
sessionKey,
elapsed,
error: data.error?.message || JSON.stringify(data),
error: data?.error?.message || rawText || JSON.stringify(data),
});
}
+13 -5
View File
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = path.join(process.env.HOME || "/root", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback";
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
function hasEmbeddedHttpError(reply: string): boolean {
// Some providers return error text in content while gateway still returns HTTP 200.
@@ -50,10 +50,18 @@ export async function POST() {
}),
signal: AbortSignal.timeout(100000),
});
const data = await resp.json();
const rawText = await resp.text();
const data = parseApiJsonSafely(rawText);
const elapsed = Date.now() - startTime;
if (!resp.ok) {
results.push({ agentId, ok: false, error: data.error?.message || "API error", elapsed });
if (shouldFallbackToCli(resp, rawText)) {
const fallback = await testSessionViaCli(agentId);
results.push(fallback.ok
? { agentId, ok: true, reply: fallback.reply, elapsed: fallback.elapsed }
: { agentId, ok: false, error: fallback.error || "Gateway route not found", elapsed: fallback.elapsed });
} else {
results.push({ agentId, ok: false, error: data?.error?.message || rawText || "API error", elapsed });
}
} else {
const reply = data.choices?.[0]?.message?.content || "";
const clippedReply = reply.slice(0, 200) || "(no reply)";
+24 -2
View File
@@ -222,6 +222,7 @@ export default function PixelOfficePage() {
const panRef = useRef<{ x: number; y: number }>(cachedPan)
const savedLayoutRef = useRef<OfficeLayout | null>(cachedSavedLayout)
const animationFrameIdRef = useRef<number | null>(null)
const officeReadyRef = useRef<boolean>(false)
const prevAgentStatesRef = useRef<Map<string, string>>(new Map(cachedPrevAgentStates))
const seenSubagentEventKeysRef = useRef<Map<string, number>>(new Map())
@@ -390,6 +391,10 @@ export default function PixelOfficePage() {
return () => mql.removeEventListener("change", apply)
}, [])
useEffect(() => {
officeReadyRef.current = officeReady
}, [officeReady])
// Load saved layout and sound preference
useEffect(() => {
const loadLayout = async () => {
@@ -452,6 +457,23 @@ export default function PixelOfficePage() {
}
}, [])
useEffect(() => {
const office = officeRef.current
if (!officeReady || !office || cachedAgents.length === 0) return
for (const [agentId, charId] of agentIdMapRef.current) {
office.removeAllSubagents(charId)
office.removeAgent(charId)
agentIdMapRef.current.delete(agentId)
}
nextIdRef.current.current = 1
setAgents(cachedAgents)
syncAgentsToOffice(cachedAgents, office, agentIdMapRef.current, nextIdRef.current)
cachedAgentIdMap = new Map(agentIdMapRef.current)
cachedNextCharacterId = nextIdRef.current.current
}, [officeReady])
useEffect(() => {
if (soundOn) {
void playBackgroundMusic()
@@ -727,7 +749,7 @@ export default function PixelOfficePage() {
useEffect(() => {
if (cachedAgents.length > 0) {
setAgents(cachedAgents)
if (officeRef.current) {
if (officeRef.current && officeReadyRef.current) {
syncAgentsToOffice(cachedAgents, officeRef.current, agentIdMapRef.current, nextIdRef.current)
}
}
@@ -740,7 +762,7 @@ export default function PixelOfficePage() {
cachedAgents = newAgents
const office = officeRef.current
if (office) {
if (office && officeReadyRef.current) {
syncAgentsToOffice(newAgents, office, agentIdMapRef.current, nextIdRef.current)
cachedAgentIdMap = new Map(agentIdMapRef.current)
cachedNextCharacterId = nextIdRef.current.current
+199 -10
View File
@@ -10,27 +10,216 @@ const BUGS_ENABLED_KEY = "pixel-office-bugs-enabled";
const BUGS_COUNT_KEY = "pixel-office-bugs-count";
const BUGS_MAX = 400;
const NAV_ITEMS = [
type NavIconName = "agents" | "pixelOffice" | "models" | "sessions" | "stats" | "alerts" | "skills";
type PixelTone = "base" | "shade" | "light";
type PixelRect = { x: number; y: number; w?: number; h?: number; tone?: PixelTone; opacity?: number };
type PixelPalette = { base: string; shade: string; light: string };
function PixelSvg({ pixels, className, palette }: { pixels: PixelRect[]; className?: string; palette: PixelPalette }) {
const fillForTone = (tone: PixelTone = "base") => {
if (tone === "shade") return palette.shade;
if (tone === "light") return palette.light;
return palette.base;
};
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={className}
style={{ imageRendering: "pixelated", shapeRendering: "crispEdges" }}
>
{pixels.map((pixel, index) => (
<rect
key={index}
x={pixel.x}
y={pixel.y}
width={pixel.w ?? 1}
height={pixel.h ?? 1}
fill={fillForTone(pixel.tone)}
opacity={pixel.opacity ?? 1}
/>
))}
</svg>
);
}
function navPalette(active: boolean): PixelPalette {
return active
? { base: "var(--accent)", shade: "color-mix(in srgb, var(--accent) 72%, black)", light: "color-mix(in srgb, var(--accent) 55%, white)" }
: { base: "var(--text)", shade: "color-mix(in srgb, var(--text) 62%, black)", light: "color-mix(in srgb, var(--text) 35%, white)" };
}
function NavPixelIcon({ name, active }: { name: NavIconName; active: boolean }) {
const baseClass = "h-4 w-4";
const palette = navPalette(active);
switch (name) {
case "agents":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 5, y: 1, w: 6, h: 1, tone: "light" },
{ x: 4, y: 2, w: 8, h: 1, tone: "base" },
{ x: 3, y: 3, w: 10, h: 2, tone: "base" },
{ x: 4, y: 5, w: 8, h: 1, tone: "shade" },
{ x: 2, y: 7, w: 12, h: 1, tone: "base" },
{ x: 1, y: 8, w: 3, h: 5, tone: "shade" },
{ x: 6, y: 8, w: 4, h: 2, tone: "light", opacity: 0.95 },
{ x: 12, y: 8, w: 3, h: 5, tone: "shade" },
{ x: 5, y: 10, w: 6, h: 4, tone: "base" },
{ x: 6, y: 6, w: 1, h: 1, tone: "light", opacity: 0.65 },
{ x: 9, y: 6, w: 1, h: 1, tone: "light", opacity: 0.65 },
]}
/>
);
case "pixelOffice":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 1, y: 4, w: 14, h: 1, tone: "shade" },
{ x: 1, y: 5, w: 2, h: 8, tone: "base" },
{ x: 13, y: 5, w: 2, h: 8, tone: "base" },
{ x: 5, y: 1, w: 6, h: 2, tone: "light" },
{ x: 4, y: 3, w: 8, h: 1, tone: "base" },
{ x: 4, y: 7, w: 3, h: 3, tone: "shade" },
{ x: 9, y: 7, w: 3, h: 3, tone: "shade" },
{ x: 6, y: 10, w: 4, h: 3, tone: "light", opacity: 0.9 },
]}
/>
);
case "models":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 5, y: 1, w: 6, h: 1, tone: "light" },
{ x: 3, y: 3, w: 10, h: 1, tone: "base" },
{ x: 2, y: 5, w: 12, h: 1, tone: "shade" },
{ x: 3, y: 7, w: 10, h: 1, tone: "base" },
{ x: 5, y: 9, w: 6, h: 1, tone: "light" },
{ x: 4, y: 2, w: 1, h: 1, tone: "light", opacity: 0.8 },
{ x: 11, y: 2, w: 1, h: 1, tone: "light", opacity: 0.8 },
{ x: 1, y: 4, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 14, y: 4, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 4, y: 11, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 11, y: 11, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 6, y: 13, w: 4, h: 1, tone: "base" },
]}
/>
);
case "sessions":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 2, y: 3, w: 10, h: 7, tone: "base" },
{ x: 4, y: 10, w: 4, h: 2, tone: "shade" },
{ x: 10, y: 10, w: 2, h: 2, tone: "shade" },
{ x: 4, y: 5, w: 6, h: 1, tone: "light", opacity: 0.6 },
{ x: 4, y: 7, w: 4, h: 1, tone: "light", opacity: 0.6 },
{ x: 11, y: 6, w: 3, h: 5, tone: "shade", opacity: 0.9 },
{ x: 12, y: 5, w: 2, h: 1, tone: "light", opacity: 0.85 },
]}
/>
);
case "stats":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 2, y: 12, w: 12, h: 1, tone: "shade", opacity: 0.7 },
{ x: 3, y: 9, w: 2, h: 3, tone: "base" },
{ x: 7, y: 6, w: 2, h: 6, tone: "base" },
{ x: 11, y: 3, w: 2, h: 9, tone: "light" },
{ x: 2, y: 4, w: 2, h: 2, tone: "shade", opacity: 0.65 },
{ x: 5, y: 2, w: 2, h: 2, tone: "light", opacity: 0.65 },
]}
/>
);
case "alerts":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 7, y: 1, w: 2, h: 1, tone: "light" },
{ x: 5, y: 2, w: 6, h: 1, tone: "base" },
{ x: 4, y: 3, w: 8, h: 1, tone: "base" },
{ x: 4, y: 4, w: 8, h: 5, tone: "shade" },
{ x: 3, y: 9, w: 10, h: 2, tone: "base" },
{ x: 6, y: 12, w: 4, h: 1, tone: "light" },
{ x: 5, y: 13, w: 6, h: 1, tone: "shade", opacity: 0.8 },
]}
/>
);
case "skills":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 6, y: 1, w: 4, h: 2, tone: "light" },
{ x: 4, y: 3, w: 8, h: 2, tone: "base" },
{ x: 2, y: 5, w: 12, h: 6, tone: "shade" },
{ x: 4, y: 11, w: 8, h: 2, tone: "base" },
{ x: 6, y: 13, w: 4, h: 2, tone: "light" },
{ x: 7, y: 6, w: 2, h: 4, tone: "base", opacity: 0.5 },
{ x: 5, y: 7, w: 6, h: 2, tone: "light", opacity: 0.45 },
]}
/>
);
}
}
function NavItemIcon({ name, active }: { name: NavIconName; active: boolean }) {
return (
<span
className={`inline-flex h-8 w-8 items-center justify-center border transition-colors ${
active
? "border-[var(--accent)]/45 bg-[var(--accent)]/14"
: "border-[var(--border)] bg-[var(--bg)]/88"
}`}
style={{
borderRadius: 0,
boxShadow: active
? "inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -2px 0 rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.04)"
: "inset 0 1px 0 rgba(255,255,255,0.08), inset 0 -2px 0 rgba(0,0,0,0.1)",
}}
>
<NavPixelIcon name={name} active={active} />
</span>
);
}
const NAV_ITEMS: { group: string; items: { href: string; icon: NavIconName; labelKey: string }[] }[] = [
{
group: "nav.overview",
items: [
{ href: "/", icon: "🤖", labelKey: "nav.agents" },
{ href: "/pixel-office", icon: "🎮", labelKey: "nav.pixelOffice" },
{ href: "/models", icon: "🧠", labelKey: "nav.models" },
{ href: "/", icon: "agents", labelKey: "nav.agents" },
{ href: "/pixel-office", icon: "pixelOffice", labelKey: "nav.pixelOffice" },
{ href: "/models", icon: "models", labelKey: "nav.models" },
],
},
{
group: "nav.monitor",
items: [
{ href: "/sessions", icon: "💬", labelKey: "nav.sessions" },
{ href: "/stats", icon: "📊", labelKey: "nav.stats" },
{ href: "/alerts", icon: "🔔", labelKey: "nav.alerts" },
{ href: "/sessions", icon: "sessions", labelKey: "nav.sessions" },
{ href: "/stats", icon: "stats", labelKey: "nav.stats" },
{ href: "/alerts", icon: "alerts", labelKey: "nav.alerts" },
],
},
{
group: "nav.config",
items: [
{ href: "/skills", icon: "🧩", labelKey: "nav.skills" },
{ href: "/skills", icon: "skills", labelKey: "nav.skills" },
],
},
];
@@ -341,7 +530,7 @@ export function Sidebar() {
: "text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg)]"
}`}
>
<span className="text-base">{item.icon}</span>
<NavItemIcon name={item.icon} active={active} />
{t(item.labelKey)}
</Link>
);
@@ -513,7 +702,7 @@ export function Sidebar() {
gap: collapsed ? 0 : 10,
}}
>
<span className="text-base">{item.icon}</span>
<NavItemIcon name={item.icon} active={active} />
{!collapsed && t(item.labelKey)}
</Link>
);
+160 -71
View File
@@ -18,54 +18,133 @@ interface AgentInfo {
emoji: string;
}
function normalizeSkill(raw: unknown): Skill | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
const id = typeof value.id === "string" ? value.id : "";
const name = typeof value.name === "string" && value.name.trim() ? value.name : id;
const source = typeof value.source === "string" && value.source.trim() ? value.source : "custom";
if (!id) return null;
return {
id,
name,
description: typeof value.description === "string" ? value.description : "",
emoji: typeof value.emoji === "string" && value.emoji.trim() ? value.emoji : "🧩",
source,
usedBy: Array.isArray(value.usedBy)
? value.usedBy.filter((agentId): agentId is string => typeof agentId === "string" && agentId.trim().length > 0)
: [],
};
}
function normalizeAgents(raw: unknown): Record<string, AgentInfo> {
if (!raw || typeof raw !== "object") return {};
const entries = Object.entries(raw as Record<string, unknown>)
.map(([agentId, info]) => {
if (!info || typeof info !== "object") return null;
const value = info as Record<string, unknown>;
return [
agentId,
{
name: typeof value.name === "string" && value.name.trim() ? value.name : agentId,
emoji: typeof value.emoji === "string" && value.emoji.trim() ? value.emoji : "🤖",
},
] as const;
})
.filter((entry): entry is readonly [string, AgentInfo] => Boolean(entry));
return Object.fromEntries(entries);
}
export default function SkillsPage() {
const { t } = useI18n();
const [skills, setSkills] = useState<Skill[]>([]);
const [agents, setAgents] = useState<Record<string, AgentInfo>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
fetch("/api/skills")
.then((r) => r.json())
.then(async (response) => {
const data = await response.json();
if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
return data;
})
.then((data) => {
if (data.error) setError(data.error);
else {
setSkills(data.skills);
setAgents(data.agents);
if (cancelled) return;
if (data?.error) {
setError(data.error);
return;
}
const rawSkills = Array.isArray(data?.skills) ? (data.skills as unknown[]) : [];
const normalizedSkills = rawSkills
.map(normalizeSkill)
.filter((skill: Skill | null): skill is Skill => skill !== null);
setSkills(normalizedSkills);
setAgents(normalizeAgents(data?.agents));
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
}
})
.catch((e) => setError(e.message));
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const filtered = skills.filter((s) => {
if (filter === "builtin" && s.source !== "builtin") return false;
if (filter === "extension" && !s.source.startsWith("extension:")) return false;
if (filter === "custom" && s.source !== "custom") return false;
if (search) {
const q = search.toLowerCase();
return (
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q) ||
s.id.toLowerCase().includes(q)
);
}
return true;
const filtered = skills.filter((skill) => {
if (filter === "builtin" && skill.source !== "builtin") return false;
if (filter === "extension" && !skill.source.startsWith("extension:")) return false;
if (filter === "custom" && skill.source !== "custom") return false;
if (!search) return true;
const query = search.toLowerCase();
return (
skill.name.toLowerCase().includes(query) ||
skill.description.toLowerCase().includes(query) ||
skill.id.toLowerCase().includes(query)
);
});
const sourceLabel = (s: string) => {
if (s === "builtin") return t("skills.source.builtin");
if (s.startsWith("extension:")) return s.replace("extension:", `${t("skills.extension")}:`);
const sourceLabel = (source: string) => {
if (source === "builtin") return t("skills.source.builtin");
if (source.startsWith("extension:")) return source.replace("extension:", `${t("skills.extension")}:`);
return t("skills.source.custom");
};
const sourceBadgeClass = (s: string) => {
if (s === "builtin") return "bg-blue-500/20 text-blue-400";
if (s.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
const sourceBadgeClass = (source: string) => {
if (source === "builtin") return "bg-blue-500/20 text-blue-400";
if (source.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
return "bg-green-500/20 text-green-400";
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -74,9 +153,9 @@ export default function SkillsPage() {
);
}
const builtinCount = skills.filter((s) => s.source === "builtin").length;
const extCount = skills.filter((s) => s.source.startsWith("extension:")).length;
const customCount = skills.filter((s) => s.source === "custom").length;
const builtinCount = skills.filter((skill) => skill.source === "builtin").length;
const extensionCount = skills.filter((skill) => skill.source.startsWith("extension:")).length;
const customCount = skills.filter((skill) => skill.source === "custom").length;
return (
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
@@ -84,7 +163,7 @@ export default function SkillsPage() {
<div>
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{skills.length} {t("skills.count")}{t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extCount} / {t("skills.custom")} {customCount}
{skills.length} {t("skills.count")}{t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
@@ -97,20 +176,25 @@ export default function SkillsPage() {
</div>
</div>
{/* Filters */}
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center">
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
{(["all", "builtin", "extension", "custom"] as const).map((f) => (
{(["all", "builtin", "extension", "custom"] as const).map((nextFilter) => (
<button
key={f}
onClick={() => setFilter(f)}
key={nextFilter}
onClick={() => setFilter(nextFilter)}
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
filter === f
filter === nextFilter
? "bg-[var(--accent)] text-[var(--bg)]"
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
}`}
>
{f === "all" ? t("skills.all") : f === "builtin" ? t("skills.builtin") : f === "extension" ? t("skills.extension") : t("skills.custom")}
{nextFilter === "all"
? t("skills.all")
: nextFilter === "builtin"
? t("skills.builtin")
: nextFilter === "extension"
? t("skills.extension")
: t("skills.custom")}
</button>
))}
</div>
@@ -118,7 +202,7 @@ export default function SkillsPage() {
type="text"
placeholder={t("skills.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(event) => setSearch(event.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
/>
<span className="text-xs text-[var(--text-muted)]">
@@ -126,43 +210,48 @@ export default function SkillsPage() {
</span>
</div>
{/* Skills Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((skill) => (
<div
key={`${skill.source}-${skill.id}`}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{skill.emoji}</span>
<span className="font-semibold text-sm">{skill.name}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${sourceBadgeClass(skill.source)}`}>
{sourceLabel(skill.source)}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
const agent = agents[agentId];
return (
<span
key={agentId}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
>
{agent?.emoji || "🤖"} {agent?.name || agentId}
</span>
);
})}
</div>
)}
{filtered.length === 0 ? (
<div className="col-span-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-8 text-center text-[var(--text-muted)] text-sm">
{t("common.noData")}
</div>
))}
) : (
filtered.map((skill) => (
<div
key={`${skill.source}-${skill.id}`}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
>
<div className="flex items-start justify-between mb-2 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl shrink-0">{skill.emoji}</span>
<span className="font-semibold text-sm truncate">{skill.name}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${sourceBadgeClass(skill.source)}`}>
{sourceLabel(skill.source)}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
const agent = agents[agentId];
return (
<span
key={agentId}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
>
{agent?.emoji || "🤖"} {agent?.name || agentId}
</span>
);
})}
</div>
)}
</div>
))
)}
</div>
</main>
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import fs, { promises as fsPromises } from "fs";
export function stripUtf8Bom(text: string): string {
return text.replace(/^\uFEFF/, "");
}
export function parseJsonText<T = any>(text: string): T {
return JSON.parse(stripUtf8Bom(text)) as T;
}
export function readJsonFileSync<T = any>(filePath: string): T {
return parseJsonText<T>(fs.readFileSync(filePath, "utf-8"));
}
export async function readJsonFile<T = any>(filePath: string): Promise<T> {
return parseJsonText<T>(await fsPromises.readFile(filePath, "utf-8"));
}
+30 -15
View File
@@ -1,9 +1,11 @@
import fs from "fs";
import path from "path";
import { execFile } from "child_process";
import { exec, execFile } from "child_process";
import { promisify } from "util";
import { readJsonFileSync } from "@/lib/json";
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
export const DEFAULT_MODEL_PROBE_TIMEOUT_MS = 15000;
@@ -55,9 +57,31 @@ interface ProbeModelParams {
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 quoteShellArg(arg: string): string {
if (/^[A-Za-z0-9_./:=@-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '""')}"`;
}
async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
}
const command = `openclaw ${args.map(quoteShellArg).join(" ")}`;
return execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
env,
shell: "cmd.exe",
});
}
function parseJsonFromMixedOutput(output: string): any {
for (let i = 0; i < output.length; i++) {
if (output[i] !== "{") continue;
@@ -95,8 +119,7 @@ function parseJsonFromMixedOutput(output: string): any {
function loadProviderConfig(providerId: string): ProviderConfig | null {
try {
const raw = fs.readFileSync(MODELS_PATH, "utf-8");
const parsed = JSON.parse(raw);
const parsed = readJsonFileSync<any>(MODELS_PATH);
const providers = parsed?.providers;
if (!providers || typeof providers !== "object") return null;
const exact = providers[providerId];
@@ -281,9 +304,7 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
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",
[
const { stdout, stderr } = await execOpenclaw([
"models",
"status",
"--probe",
@@ -292,12 +313,7 @@ async function probeProviderViaOpenclaw(params: ProbeModelParams): Promise<Model
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}`;
@@ -349,4 +365,3 @@ export async function probeModel(params: ProbeModelParams): Promise<ModelProbeOu
}
return probeProviderViaOpenclaw(params);
}
+63
View File
@@ -0,0 +1,63 @@
import { exec, execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
function quoteShellArg(arg: string): string {
if (/^[A-Za-z0-9_./:=@-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '""')}"`;
}
export async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
}
const command = `openclaw ${args.map(quoteShellArg).join(" ")}`;
return execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
env,
shell: "cmd.exe",
});
}
export 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 {
return JSON.parse(candidate);
} catch {
break;
}
}
}
}
}
return null;
}
+34
View File
@@ -0,0 +1,34 @@
import os from "os";
import path from "path";
const home = os.homedir();
export const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(home, ".openclaw");
export const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
export const OPENCLAW_AGENTS_DIR = path.join(OPENCLAW_HOME, "agents");
export const OPENCLAW_PIXEL_OFFICE_DIR = path.join(OPENCLAW_HOME, "pixel-office");
function uniquePaths(paths: Array<string | undefined>): string[] {
return Array.from(new Set(paths.filter((value): value is string => Boolean(value && value.trim()))));
}
export function getOpenclawPackageCandidates(version = process.version): string[] {
const appData = process.env.APPDATA;
const homebrewPrefix = process.env.HOMEBREW_PREFIX;
const npmPrefix = process.env.npm_config_prefix || process.env.PREFIX;
return uniquePaths([
process.env.OPENCLAW_PACKAGE_DIR,
npmPrefix ? path.join(npmPrefix, "node_modules", "openclaw") : undefined,
path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"),
path.join(home, ".fnm", "node-versions", version, "installation", "lib", "node_modules", "openclaw"),
path.join(home, ".npm-global", "lib", "node_modules", "openclaw"),
path.join(home, ".local", "share", "pnpm", "global", "5", "node_modules", "openclaw"),
path.join(home, "Library", "pnpm", "global", "5", "node_modules", "openclaw"),
appData ? path.join(appData, "npm", "node_modules", "openclaw") : undefined,
homebrewPrefix ? path.join(homebrewPrefix, "lib", "node_modules", "openclaw") : undefined,
"/opt/homebrew/lib/node_modules/openclaw",
"/usr/local/lib/node_modules/openclaw",
"/usr/lib/node_modules/openclaw",
]);
}
+11 -8
View File
@@ -1,6 +1,5 @@
import { TILE_SIZE, MATRIX_EFFECT_DURATION, CharacterState, Direction } from '../types'
import {
PALETTE_COUNT,
HUE_SHIFT_MIN_DEG,
HUE_SHIFT_RANGE_DEG,
WAITING_BUBBLE_DURATION_SEC,
@@ -15,6 +14,7 @@ import {
} from '../constants'
import type { Character, Seat, FurnitureInstance, TileType as TileTypeVal, OfficeLayout, PlacedFurniture } from '../types'
import { createCharacter, updateCharacter } from './characters'
import { CHARACTER_PALETTES, getAvailableCharacterVariantCount } from '../sprites/spriteData'
import { matrixEffectSeeds } from './matrixEffect'
import { isWalkable, getWalkableTiles, findPath } from '../layout/tileMap'
import {
@@ -604,23 +604,26 @@ export class OfficeState {
/**
* Pick a diverse palette for a new agent based on currently active agents.
* First 6 agents each get a unique skin (random order). Beyond 6, skins
* First round uses each available character variant once (random order).
* Beyond that, variants
* repeat in balanced rounds with a random hue shift (≥45°).
*/
private pickDiversePalette(): { palette: number; hueShift: number } {
// Count how many non-sub-agents use each base palette (0-5)
const counts = new Array(PALETTE_COUNT).fill(0) as number[]
const paletteCount = getAvailableCharacterVariantCount()
const counts = new Array(paletteCount).fill(0) as number[]
for (const ch of this.characters.values()) {
if (ch.isSubagent) continue
counts[ch.palette]++
counts[ch.palette % paletteCount]++
}
const minCount = Math.min(...counts)
// Available = palettes at the minimum count (least used)
// Available = variants at the minimum count (least used)
const available: number[] = []
for (let i = 0; i < PALETTE_COUNT; i++) {
for (let i = 0; i < paletteCount; i++) {
if (counts[i] === minCount) available.push(i)
}
const palette = available[Math.floor(Math.random() * available.length)]
const extraVariants = available.filter((index) => index >= CHARACTER_PALETTES.length)
const preferred = minCount === 0 && extraVariants.length > 0 ? extraVariants : available
const palette = preferred[Math.floor(Math.random() * preferred.length)]
// First round (minCount === 0): no hue shift. Subsequent rounds: random ≥45°.
let hueShift = 0
if (minCount > 0) {
+101 -9
View File
@@ -7,13 +7,9 @@ import { setWallSprites } from '../wallTiles'
* Load a PNG image and convert it to SpriteData (2D array of hex color strings).
* Transparent pixels become '' (empty string).
*/
function pngToSpriteData(img: HTMLImageElement): SpriteData {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
function canvasToSpriteData(canvas: HTMLCanvasElement): SpriteData {
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, img.width, img.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const { data, width, height } = imageData
const result: string[][] = []
@@ -33,6 +29,15 @@ function pngToSpriteData(img: HTMLImageElement): SpriteData {
return result
}
function pngToSpriteData(img: HTMLImageElement): SpriteData {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
return canvasToSpriteData(canvas)
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
@@ -42,6 +47,76 @@ function loadImage(src: string): Promise<HTMLImageElement> {
})
}
function normalizedSpriteData(img: HTMLImageElement, targetWidth?: number, targetHeight?: number): SpriteData {
const canvas = document.createElement('canvas')
const shouldResize =
typeof targetWidth === 'number' &&
typeof targetHeight === 'number' &&
img.width !== targetWidth &&
img.height !== targetHeight &&
img.width % targetWidth === 0 &&
img.height % targetHeight === 0
if (shouldResize) {
canvas.width = targetWidth
canvas.height = targetHeight
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, 0, 0, targetWidth, targetHeight)
return canvasToSpriteData(canvas)
}
return pngToSpriteData(img)
}
function stripOpaqueSheetBackground(sprite: SpriteData): SpriteData {
if (sprite.length === 0 || sprite[0].length === 0) return sprite
if (sprite.some((row) => row.some((pixel) => pixel === ''))) return sprite
const height = sprite.length
const width = sprite[0].length
const result = sprite.map((row) => [...row])
const visited = Array.from({ length: height }, () => Array(width).fill(false))
const queue: Array<[number, number]> = []
const brightness = (hex: string): number => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return 0.299 * r + 0.587 * g + 0.114 * b
}
const corners = [sprite[0][0], sprite[0][width - 1], sprite[height - 1][0], sprite[height - 1][width - 1]]
const threshold = Math.max(140, Math.min(...corners.map((pixel) => brightness(pixel))) - 12)
const enqueue = (x: number, y: number) => {
if (visited[y][x]) return
if (brightness(result[y][x]) < threshold) return
visited[y][x] = true
queue.push([x, y])
}
for (let x = 0; x < width; x++) {
enqueue(x, 0)
enqueue(x, height - 1)
}
for (let y = 0; y < height; y++) {
enqueue(0, y)
enqueue(width - 1, y)
}
while (queue.length > 0) {
const [x, y] = queue.shift()!
result[y][x] = ''
if (x > 0) enqueue(x - 1, y)
if (x + 1 < width) enqueue(x + 1, y)
if (y > 0) enqueue(x, y - 1)
if (y + 1 < height) enqueue(x, y + 1)
}
return result
}
/**
* Extract a sub-region from a SpriteData array.
*/
@@ -74,16 +149,33 @@ function parseCharacterSheet(sheet: SpriteData): LoadedCharacterData {
/**
* Load character PNGs from /assets/pixel-office/characters/ and register them.
* Falls back silently to hardcoded templates if loading fails.
* Loads the default set plus any extra contiguous char_N.png files.
* Falls back silently to hardcoded templates if the base set fails.
*/
export async function loadCharacterPNGs(): Promise<boolean> {
try {
const characters: LoadedCharacterData[] = []
for (let i = 0; i < 6; i++) {
const baseCharacterCount = 6
const maxCharacterCount = 64
const CHARACTER_SHEET_WIDTH = 112
const CHARACTER_SHEET_HEIGHT = 96
for (let i = 0; i < baseCharacterCount; i++) {
const img = await loadImage(`/assets/pixel-office/characters/char_${i}.png`)
const sheet = pngToSpriteData(img)
const sheet = stripOpaqueSheetBackground(normalizedSpriteData(img, CHARACTER_SHEET_WIDTH, CHARACTER_SHEET_HEIGHT))
characters.push(parseCharacterSheet(sheet))
}
for (let i = baseCharacterCount; i < maxCharacterCount; i++) {
try {
const img = await loadImage(`/assets/pixel-office/characters/char_${i}.png`)
const sheet = stripOpaqueSheetBackground(normalizedSpriteData(img, CHARACTER_SHEET_WIDTH, CHARACTER_SHEET_HEIGHT))
characters.push(parseCharacterSheet(sheet))
} catch {
break
}
}
setCharacterTemplates(characters)
return true
} catch (e) {
+4
View File
@@ -1123,6 +1123,10 @@ export interface LoadedCharacterData {
let loadedCharacters: LoadedCharacterData[] | null = null
export function getAvailableCharacterVariantCount(): number {
return loadedCharacters?.length ?? CHARACTER_PALETTES.length
}
/** Set pre-colored character sprites loaded from PNG assets. Call this when characterSpritesLoaded message arrives. */
export function setCharacterTemplates(data: LoadedCharacterData[]): void {
loadedCharacters = data
+61
View File
@@ -0,0 +1,61 @@
import { execOpenclaw, parseJsonFromMixedOutput } from "@/lib/openclaw-cli";
function extractCliReply(parsed: any, stdout: string): string {
const candidates = [
parsed?.reply,
parsed?.text,
parsed?.outputText,
parsed?.result?.reply,
parsed?.result?.text,
parsed?.response?.text,
parsed?.response?.output_text,
parsed?.message,
];
const hit = candidates.find((value) => typeof value === "string" && value.trim());
if (hit) return hit.trim().slice(0, 200);
if (parsed?.status === "ok") {
const summary = typeof parsed?.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : "completed";
return `OK (${summary}, CLI fallback)`;
}
return (stdout || "(no reply)").trim().slice(0, 200);
}
export async function testSessionViaCli(agentId: string): Promise<{ ok: boolean; reply?: string; error?: string; elapsed: number }> {
const startTime = Date.now();
try {
const { stdout, stderr } = await execOpenclaw([
"agent",
"--agent",
agentId,
"--message",
"Health check: reply with OK",
"--json",
"--timeout",
"100",
]);
const elapsed = Date.now() - startTime;
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
const error = parsed?.error?.message || parsed?.error;
if (typeof error === "string" && error.trim()) {
return { ok: false, error: error.trim().slice(0, 300), elapsed };
}
return { ok: true, reply: extractCliReply(parsed, stdout), elapsed };
} catch (err: any) {
const elapsed = Date.now() - startTime;
return { ok: false, error: (err?.message || "CLI fallback failed").slice(0, 300), elapsed };
}
}
export function shouldFallbackToCli(resp: Response, rawText: string): boolean {
const text = rawText.trim();
return resp.status === 404 || text === "Not Found";
}
export function parseApiJsonSafely(rawText: string): any {
if (!rawText) return null;
try {
return JSON.parse(rawText);
} catch {
return null;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB