Merge PR #33 with conflict resolution

This commit is contained in:
xmanrui
2026-06-05 03:47:30 +08:00
25 changed files with 2395 additions and 127 deletions
+2
View File
@@ -11,3 +11,5 @@ next-env.d.ts
rd-council-work-orders.json
self-improvement-command-log.jsonl
rd-council-decision-log.jsonl
.env.local
.env.*.local
+295 -16
View File
@@ -9,7 +9,7 @@ export const revalidate = 0
const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
const MAX_PARENT_SESSIONS_TO_PARSE = 40
const ORPHAN_FALLBACK_WINDOW_MS = 15 * 60 * 1000
const SUBAGENT_MAX_ACTIVE_MS = 30 * 60 * 1000
const SUBAGENT_MAX_ACTIVE_MS = 10 * 60 * 1000
const SUBAGENT_ACTIVITY_EVENT_LIMIT = 6
const SUBAGENT_ACTIVITY_TEXT_MAX_LEN = 80
@@ -68,6 +68,7 @@ export interface AgentActivity {
lastActive: number
subagents?: SubagentInfo[]
cronJobs?: CronJobInfo[]
lastTask?: string
}
type AgentConfigEntry = {
@@ -115,11 +116,11 @@ function pickSubagentLabel(raw: unknown): string {
function extractCompletedSubagentLabel(text: string): string | null {
if (!text) return null
const patterns = [
/A subagent task\s+"([^"]+)"\s+just completed/i,
/A subagent task\s+([^]+)\s+just completed/i,
/A subagent task\s+'([^']+)'\s+just completed/i,
/subagent task\s+"([^"]+)"\s+.*completed/i,
/subagent task\s+([^]+)\s+.*completed/i,
/subagent task\s+'([^']+)'\s+.*completed/i,
/子任务[“"]([^”"]+)[”"].{0,12}完成/,
/子任务[“]([^”]+)[”].{0,12}完成/,
]
for (const p of patterns) {
const m = text.match(p)
@@ -128,6 +129,32 @@ function extractCompletedSubagentLabel(text: string): string | null {
return null
}
/**
* Extract agentId from “✅ Subagent {agentId} finished” pattern in assistant messages.
* e.g. “✅ Subagent agentxq finished” → “agentxq”
*/
function extractFinishedSubagentId(text: string): string | null {
if (!text) return null
const m = text.match(/(?:✅|☑️|✓)\s*Subagent\s+(\S+)\s+finished/i)
return m?.[1]?.trim() ?? null
}
/**
* Given a finished agentId, remove matching subtasks from activeSubtasks.
* Matches on childSessionKey containing “:agentId:” (e.g. “agent:agentxq:subagent:xxx”).
*/
function removeFinishedSubagentById(
agentId: string,
activeSubtasks: Map<string, { label: string; at: number; childSessionKey?: string }>,
): void {
for (const [id, state] of activeSubtasks.entries()) {
if (state.childSessionKey && state.childSessionKey.includes(`:${agentId}:`)) {
activeSubtasks.delete(id)
return
}
}
}
function parseRecordTimestamp(record: unknown): number {
if (!record || typeof record !== 'object') return 0
const rec = record as Record<string, unknown>
@@ -518,8 +545,10 @@ async function parseSubagentsFromSessionFile(
const content = await fs.readFile(filePath, 'utf8')
const lines = content.split('\n').filter(l => l.trim())
const activeSubtasks = new Map<string, { label: string; at: number; childSessionKey?: string }>()
const activeSubtasks = new Map<string, { label: string; at: number; childSessionKey?: string; acceptedAt?: number }>()
const spawnToolIds = new Set<string>()
/** toolIds whose spawn was accepted and are awaiting a text response from parent */
const pendingResponseIds = new Set<string>()
for (const line of lines) {
try {
@@ -530,6 +559,15 @@ async function parseSubagentsFromSessionFile(
if (record.type === 'assistant' && record.message?.content) {
const blocks = Array.isArray(record.message.content) ? record.message.content : []
for (const block of blocks) {
if (block.type === 'text' && typeof block.text === 'string') {
const finishedId = extractFinishedSubagentId(block.text)
if (finishedId) { removeFinishedSubagentById(finishedId, activeSubtasks); pendingResponseIds.clear() }
if (pendingResponseIds.size > 0) {
for (const id of pendingResponseIds) activeSubtasks.delete(id)
pendingResponseIds.clear()
}
continue
}
if (block.type !== 'tool_use' || typeof block.id !== 'string' || !block.id) continue
if (typeof block.name === 'string' && isSpawnTool(block.name)) {
activeSubtasks.set(block.id, { label: pickSubagentLabel(block.input), at: eventAt })
@@ -565,6 +603,19 @@ async function parseSubagentsFromSessionFile(
const blocks = Array.isArray(msg.content) ? msg.content : []
if (role === 'assistant') {
for (const block of blocks) {
// Check text blocks for completion signals
if (block?.type === 'text' && typeof block.text === 'string') {
// Pattern 1: "✅ Subagent {agentId} finished"
const finishedId = extractFinishedSubagentId(block.text)
if (finishedId) { removeFinishedSubagentById(finishedId, activeSubtasks); pendingResponseIds.clear() }
// Pattern 2: any assistant text reply clears spawns that were awaiting a response
// (handles custom responses like "✅ 已叫起 agentdev", "起來了!", etc.)
if (pendingResponseIds.size > 0) {
for (const id of pendingResponseIds) activeSubtasks.delete(id)
pendingResponseIds.clear()
}
continue
}
if (block?.type === 'toolCall' && typeof block.id === 'string' && block.id) {
if (typeof block.name === 'string' && isSpawnTool(block.name)) {
activeSubtasks.set(block.id, { label: pickSubagentLabel(block.arguments), at: eventAt })
@@ -581,9 +632,10 @@ async function parseSubagentsFromSessionFile(
const toolName = typeof msg.toolName === 'string' ? msg.toolName : ''
if (toolCallId && spawnToolIds.has(toolCallId)) {
const childSessionKey = extractChildSessionKeyFromToolResultMessage(msg)
if (childSessionKey && activeSubtasks.has(toolCallId)) {
if (activeSubtasks.has(toolCallId)) {
const prev = activeSubtasks.get(toolCallId)!
activeSubtasks.set(toolCallId, { ...prev, childSessionKey })
activeSubtasks.set(toolCallId, { ...prev, childSessionKey: childSessionKey || prev.childSessionKey, acceptedAt: eventAt })
pendingResponseIds.add(toolCallId)
}
continue
}
@@ -611,8 +663,10 @@ async function parseSubagentsFromSessionFile(
}
const now = Date.now()
const SPAWN_ACCEPTED_TIMEOUT_MS = 3 * 60 * 1000 // 3 min after spawn accepted — fallback
for (const [toolId, state] of activeSubtasks.entries()) {
if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue
if (state.acceptedAt && now - state.acceptedAt > SPAWN_ACCEPTED_TIMEOUT_MS) continue
const label = state.label
let activityEvents: SubagentActivityEvent[] | undefined
if (state.childSessionKey) {
@@ -768,6 +822,149 @@ async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronSto
return cronJobs
}
/**
* Extract the actual user-typed text from a session message block.
*
* Handles three formats:
* 1. Subagent spawn: contains "[Subagent Task]: ..." — extract what follows the label
* 2. Channel message (Telegram etc.): injected "Conversation info" + "Sender" code fences
* before the real text — extract what comes after the last ``` fence
* 3. Fallback: strip XML context blocks and leading timestamp
*/
function extractUserText(rawText: string): string | null {
// Strip XML context injections first (relevant-memories etc.)
const noXml = rawText.replace(/<[a-z][\s\S]*?<\/[a-z][^>]*>/gi, '')
// Strategy 1: subagent task — "[Subagent Task]: ..."
const subagentMatch = noXml.match(/\[Subagent Task\]:\s*([\s\S]+)/)
if (subagentMatch) {
return subagentMatch[1].replace(/\s+/g, ' ').trim().slice(0, 500)
}
// Strategy 2: channel message — text after last ``` fence
const lastFence = rawText.lastIndexOf('```')
if (lastFence !== -1) {
const afterFence = rawText.slice(lastFence + 3).trim()
if (afterFence.length > 3) {
return afterFence.replace(/\s+/g, ' ').trim().slice(0, 500)
}
}
// Strategy 3: strip timestamp prefix and return remaining
const noTs = noXml.replace(/^\[[^\]]{5,40}\]\s*/, '').trim()
const text = noTs.replace(/\s+/g, ' ').trim()
return text.length > 5 ? text.slice(0, 500) : null
}
/**
* Extract the last user message text from a session file — used as the agent's "last task".
*
* Priority:
* 1. The original subagent spawn task ("[Subagent Task]: ...") — scan from the start
* 2. Most recent real user message — scan from the end, skip system notifications
*/
async function extractLastUserTask(sessionFilePath: string): Promise<string | null> {
try {
const content = await fs.readFile(sessionFilePath, 'utf8')
const allLines = content.split('\n').filter(l => l.trim())
// Pass 1: find the first [Subagent Task] (set at spawn time, stable throughout session)
for (const line of allLines.slice(0, 40)) {
try {
const record = JSON.parse(line)
if (record.type !== 'message' || !record.message) continue
if (record.message.role !== 'user') continue
const blocks = Array.isArray(record.message.content) ? record.message.content : []
for (const block of blocks) {
if (block?.type !== 'text' || typeof block.text !== 'string') continue
if (!block.text.includes('[Subagent Task]')) continue
const text = extractUserText(block.text)
if (text) return text
}
} catch { /* skip */ }
}
// Pass 2: most recent real user message (direct agents, e.g. main)
const skipPhrases = ['A completed subagent task', 'Action:\n', 'END_UNTRUSTED_CHILD_RESULT', 'Continue where you left off']
const recent = allLines.slice(-80)
for (let i = recent.length - 1; i >= 0; i--) {
try {
const record = JSON.parse(recent[i])
if (record.type !== 'message' || !record.message) continue
if (record.message.role !== 'user') continue
const blocks = Array.isArray(record.message.content) ? record.message.content : []
for (const block of blocks) {
if (block?.type !== 'text' || typeof block.text !== 'string') continue
if (skipPhrases.some(p => block.text.includes(p))) continue
const text = extractUserText(block.text)
if (text) return text
}
} catch { /* skip */ }
}
} catch { /* ignore */ }
return null
}
/**
* Read last N lines of a JSONL session file and determine the agent's true working state.
*
* Logic:
* - Last message role is 'toolResult' → working (parent about to process tool output)
* - Last assistant stopReason is 'toolUse' → working (tool call in flight)
* - Last assistant stopReason is 'stop' → idle (turn completed, waiting for next input)
* - Fallback: time-based heuristic
*/
async function detectStateFromSession(
sessionFilePath: string,
now: number,
lastActive: number,
): Promise<'idle' | 'working' | 'offline'> {
if (lastActive === 0) return 'offline'
const OFFLINE_MS = 10 * 60 * 1000 // idle > 10 min → offline
const WORKING_MAX_MS = 10 * 60 * 1000 // working > 10 min → force idle
const timeDiff = now - lastActive
// Last activity > 10 min ago → offline regardless of session content
if (timeDiff > OFFLINE_MS) return 'offline'
try {
const content = await fs.readFile(sessionFilePath, 'utf8')
const lines = content.split('\n').filter(l => l.trim()).slice(-50)
let lastRole: string | null = null
let lastStopReason: string | null = null
for (let i = lines.length - 1; i >= 0; i--) {
try {
const record = JSON.parse(lines[i])
if (record.type !== 'message' || !record.message) continue
const { role, stopReason } = record.message
if (!lastRole) lastRole = role ?? null
if (role === 'assistant') {
lastStopReason = stopReason ?? null
break
}
} catch { /* skip malformed line */ }
}
// Work completed → idle ONLY if the last message itself was the assistant finishing
if (lastRole === 'assistant' && lastStopReason === 'stop') return 'idle'
// New user/tool message after assistant stop, or tool call in flight → working
if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') {
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
}
// assistant with no stopReason = mid-generation (streaming), treat as working
if (lastRole === 'assistant' && !lastStopReason) {
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
}
} catch { /* file unreadable — fall through */ }
// Fallback within the 10-min window
return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle'
}
export async function GET() {
const configPath = OPENCLAW_CONFIG_PATH
const agentsDir = OPENCLAW_AGENTS_DIR
@@ -786,34 +983,109 @@ export async function GET() {
for (const agent of agentList) {
let lastActive = 0
let mostRecentSessionFile: string | null = null
let agentSessionsDir = ''
// Resolve emoji: IDENTITY.md > agent.json > openclaw.json > default
let agentJsonEmoji: string | undefined
if (existsSync(agentsDir)) {
// 1. Read from workspace IDENTITY.md ("- **Emoji:** 🌸")
const workspaceDir = typeof (agent as any).workspace === 'string' ? (agent as any).workspace : null
if (workspaceDir) {
const identityPath = path.join(workspaceDir, 'IDENTITY.md')
if (existsSync(identityPath)) {
try {
const identityRaw = await fs.readFile(identityPath, 'utf8')
const m = identityRaw.match(/\*\*Emoji:\*\*\s*(\S+)/)
if (m?.[1]) agentJsonEmoji = m[1]
} catch { /* ignore */ }
}
}
// 2. Fallback: read from agent's agent.json emoji field
if (!agentJsonEmoji) {
const agentJsonPath = path.join(agentsDir, agent.id, 'agent', 'agent.json')
if (existsSync(agentJsonPath)) {
try {
const raw = await fs.readFile(agentJsonPath, 'utf8')
const parsed = JSON.parse(raw)
if (typeof parsed?.emoji === 'string' && parsed.emoji.trim()) {
agentJsonEmoji = parsed.emoji.trim()
}
} catch { /* ignore */ }
}
}
}
if (existsSync(agentsDir)) {
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
if (existsSync(agentSessionsDir)) {
// Use JSONL file mtime for lastActive (reliable, updates on every message write).
// Also build a map from sessionId → filePath using sessions.json, so we can
// pick the correct file for content-based state detection.
const sessionIdToFile = new Map<string, string>()
try {
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
if (existsSync(sessionsIndexPath)) {
const raw = await fs.readFile(sessionsIndexPath, 'utf8')
const index = JSON.parse(raw) as Record<string, { sessionId?: string }>
for (const [, meta] of Object.entries(index)) {
if (typeof meta.sessionId === 'string') {
sessionIdToFile.set(meta.sessionId, path.join(agentSessionsDir, `${meta.sessionId}.jsonl`))
}
}
}
} catch { /* ignore */ }
try {
const files = await fs.readdir(agentSessionsDir)
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
const filePath = path.join(agentSessionsDir, file)
const stat = await fs.stat(filePath)
if (stat.mtimeMs > lastActive) {
lastActive = stat.mtimeMs
mostRecentSessionFile = filePath
}
}
} catch {
// Ignore
} catch { /* ignore */ }
// If the most-recent .jsonl is not in sessions.json, keep it when it's
// recent (< 5 min) — it may be a brand-new session not yet indexed.
// Only fall back to the sessions.json-indexed file when the un-indexed
// file is stale, to avoid reading probe/temp files from old runs.
if (mostRecentSessionFile) {
const sessionId = path.basename(mostRecentSessionFile, '.jsonl')
if (!sessionIdToFile.has(sessionId)) {
const unindexedAge = now - lastActive
if (unindexedAge > 5 * 60 * 1000) {
// Stale un-indexed file — prefer the best sessions.json entry
let bestMtime = 0
for (const [, fp] of sessionIdToFile) {
try {
const s = await fs.stat(fp)
if (s.mtimeMs > bestMtime) {
bestMtime = s.mtimeMs
mostRecentSessionFile = fp
}
} catch { /* ignore */ }
}
if (bestMtime > 0) lastActive = bestMtime
}
// else: keep the un-indexed file — it's a fresh active session
}
}
}
}
// Determine state from session content (falls back to time-based)
let state: 'idle' | 'working' | 'waiting' | 'offline'
const timeDiff = now - lastActive
if (lastActive === 0 || timeDiff > 10 * 60 * 1000) {
state = 'offline'
} else if (timeDiff <= 2 * 60 * 1000) {
state = 'working'
if (mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
state = await detectStateFromSession(mostRecentSessionFile, now, lastActive)
} else {
state = 'idle'
const timeDiff = now - lastActive
if (lastActive === 0 || timeDiff > 10 * 60 * 1000) state = 'offline'
else if (timeDiff <= 2 * 60 * 1000) state = 'working'
else state = 'idle'
}
// Parse subagents for online agents
@@ -829,14 +1101,21 @@ export async function GET() {
if (cronJobs.length === 0) cronJobs = undefined
}
// Extract last user task for working agents
let lastTask: string | undefined
if (state === 'working' && mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined
}
agents.push({
agentId: agent.id,
name: agent.name || agent.id,
emoji: agent.identity?.emoji || agent.emoji || '🤖',
emoji: agentJsonEmoji || agent.identity?.emoji || agent.emoji || '🤖',
state,
lastActive,
subagents,
cronJobs,
lastTask,
})
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse, NextRequest } from "next/server";
import {
listBackupFiles,
restoreFromBackup,
getBackupDir,
} from "@/lib/config-backup";
/**
* GET /api/config-backup
* 列出所有可用的 openclaw.json 備份
*/
export async function GET() {
try {
const backups = listBackupFiles();
return NextResponse.json({
backupDir: getBackupDir(),
backups,
});
} catch (err: any) {
return NextResponse.json(
{ error: err.message },
{ status: 500 }
);
}
}
/**
* POST /api/config-backup
* 從指定備份還原 openclaw.json
*
* Request body: { filename: "openclaw.2026-03-15T08-30-00.json" }
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { filename } = body;
if (!filename || typeof filename !== "string") {
return NextResponse.json(
{ error: "Missing or invalid 'filename' in request body" },
{ status: 400 }
);
}
const result = restoreFromBackup(filename);
if (!result.success) {
return NextResponse.json(
{ error: result.message },
{ status: 400 }
);
}
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json(
{ error: err.message },
{ status: 500 }
);
}
}
+35 -2
View File
@@ -5,6 +5,7 @@ import { getConfigCache, setConfigCache } from "@/lib/config-cache";
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { shouldHidePlatformChannel } from "@/lib/platforms";
import { enrichModelMeta } from "@/lib/known-providers";
import { detectChangeAndBackup } from "@/lib/config-backup";
// 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
@@ -232,6 +233,25 @@ function getChannelDirectPeerIds(
return map;
}
// 从 IDENTITY.md 读取机器人名字
function readIdentityEmoji(agentId: string, agentDir?: string, workspace?: string): string | null {
const candidates = [
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
workspace ? path.join(workspace, "IDENTITY.md") : null,
path.join(OPENCLAW_DIR, `agents/${agentId}/agent/IDENTITY.md`),
path.join(OPENCLAW_DIR, `workspace-${agentId}/IDENTITY.md`),
agentId === "main" ? path.join(OPENCLAW_DIR, `workspace/IDENTITY.md`) : null,
].filter(Boolean) as string[];
for (const p of candidates) {
try {
const content = fs.readFileSync(p, "utf-8");
const match = content.match(/\*\*Emoji:\*\*\s*(\S+)/);
if (match?.[1]) return match[1].trim();
} catch {}
}
return null;
}
function readIdentityName(agentId: string, agentDir?: string, workspace?: string): string | null {
const candidates = [
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
@@ -264,6 +284,10 @@ export async function GET() {
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
// 偵測 openclaw.json 是否有變更,若有則自動備份
detectChangeAndBackup(raw);
const config = JSON.parse(raw);
// 提取 agents 信息
@@ -351,7 +375,8 @@ export async function GET() {
const id = agent.id;
const identityName = readIdentityName(id, agent.agentDir, agent.workspace);
const name = identityName || agent.name || id;
const emoji = agent.identity?.emoji || "🤖";
const identityEmoji = readIdentityEmoji(id, agent.agentDir, agent.workspace);
const emoji = identityEmoji || agent.identity?.emoji || agent.emoji || "🤖";
const model = normalizeModelRef(agent.model, defaultModel);
// 查找绑定的平台
@@ -538,6 +563,13 @@ export async function GET() {
}
}
// 取得 openclaw.json 的最後修改時間,用於前端偵測近期 config 變更
let configLastModified: string | null = null;
try {
const stat = fs.statSync(CONFIG_PATH);
configLastModified = stat.mtime.toISOString();
} catch { /* ignore */ }
const data = {
agents: agentsWithStatus,
providers,
@@ -545,9 +577,10 @@ export async function GET() {
gateway: {
port: config.gateway?.port || 18789,
token: config.gateway?.auth?.token || "",
host: config.gateway?.host || config.gateway?.hostname || "",
host: process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL || config.gateway?.host || config.gateway?.hostname || "",
},
groupChats,
configLastModified,
};
setConfigCache({ data, ts: Date.now() });
return NextResponse.json(data);
+22 -3
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import path from "path";
import { exec, execFile } from "child_process";
import { exec, execFile, execSync } from "child_process";
import { promisify } from "util";
import { readJsonFileSync } from "@/lib/json";
import { OPENCLAW_CONFIG_PATH } from "@/lib/openclaw-paths";
@@ -16,11 +16,30 @@ function quoteShellArg(arg: string): string {
return `"${arg.replace(/"/g, '""')}"`;
}
const EXTRA_PATH =
process.platform === "win32"
? "%PATH%;%APPDATA%\\npm;%LOCALAPPDATA%\\Programs\\openclaw"
: `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
let _openclawPath: string | null | undefined = undefined;
function findOpenclawPath(): string {
if (_openclawPath !== undefined) return _openclawPath ?? "openclaw";
try {
const cmd = process.platform === "win32" ? "where openclaw" : "which openclaw";
const env = { ...process.env, PATH: EXTRA_PATH };
_openclawPath = execSync(cmd, { encoding: "utf8", env }).trim().split("\n")[0].trim();
} catch {
_openclawPath = null;
}
return _openclawPath ?? "openclaw";
}
async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
const env = { ...process.env, FORCE_COLOR: "0", PATH: EXTRA_PATH };
const bin = findOpenclawPath();
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
return execFileAsync(bin, args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import os from "os";
import path from "path";
const LOG_PATH = path.join(os.homedir(), ".openclaw/logs/gateway.err.log");
const TAIL_LINES = 120;
const STALL_RECENT_MS = 10 * 60 * 1000; // consider stall "active" if within last 10 min
const PATTERNS = [
{ re: /Polling stall detected/, issue: "telegram_stall" },
{ re: /sendChatAction failed: Network request/, issue: "telegram_network" },
{ re: /gateway timeout after \d+ms/, issue: "subagent_timeout" },
] as const;
export async function GET() {
try {
const content = readFileSync(LOG_PATH, "utf8");
const lines = content.split("\n").filter(Boolean).slice(-TAIL_LINES);
const issues = new Set<string>();
for (const line of lines) {
for (const { re, issue } of PATTERNS) {
if (re.test(line)) issues.add(issue);
}
}
// Find timestamp of most recent stall line
let lastStallAt: string | null = null;
for (let i = lines.length - 1; i >= 0; i--) {
if (/Polling stall detected/.test(lines[i])) {
const m = lines[i].match(/^(\d{4}-\d{2}-\d{2}T[\d:.+]+)/);
if (m) { lastStallAt = m[1]; break; }
}
}
// Only treat stall as active if it happened recently
const stallActive = lastStallAt
? Date.now() - new Date(lastStallAt).getTime() < STALL_RECENT_MS
: false;
// 回傳最後 30 行作為原始紀錄供 UI 顯示
const recentLines = lines.slice(-30);
return NextResponse.json({
ok: true,
issues: [...issues],
lastStallAt,
stallActive,
recentLines,
});
} catch {
return NextResponse.json({ ok: false, issues: [], lastStallAt: null, stallActive: false });
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { execFile, exec, spawn } from "child_process";
import { promisify } from "util";
import fs from "fs";
import os from "os";
import path from "path";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const LAUNCHCTL = "/bin/launchctl";
const PLIST = path.join(os.homedir(), "Library/LaunchAgents/ai.openclaw.gateway.plist");
const EXTRA_PATH = `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
/** Kill any running gateway process by name */
async function killGatewayProcess(): Promise<void> {
try {
const { stdout } = await execAsync("pgrep -f 'openclaw.gateway\\|openclaw-gateway'");
const pids = stdout.trim().split("\n").filter(Boolean);
if (pids.length > 0) {
await execAsync(`kill ${pids.join(" ")}`);
await new Promise((r) => setTimeout(r, 1000));
}
} catch { /* no process running — ok */ }
}
/** Find openclaw binary in PATH */
async function findOpenclawBin(): Promise<string> {
try {
const { stdout } = await execAsync("which openclaw", { env: { ...process.env, PATH: EXTRA_PATH } });
return stdout.trim();
} catch {
return "openclaw";
}
}
export async function POST() {
try {
const hasPlist = fs.existsSync(PLIST);
if (hasPlist) {
// Plist exists — use launchctl to reload (works whether currently loaded or not)
try { await execFileAsync(LAUNCHCTL, ["unload", PLIST]); } catch { /* ignore if already unloaded */ }
await new Promise((r) => setTimeout(r, 500));
await execFileAsync(LAUNCHCTL, ["load", PLIST]);
} else {
// No plist — kill and restart directly
await killGatewayProcess();
const bin = await findOpenclawBin();
const child = spawn(bin, ["gateway"], {
detached: true,
stdio: "ignore",
env: { ...process.env, PATH: EXTRA_PATH },
});
child.unref();
}
return NextResponse.json({ ok: true, method: hasPlist ? "launchd" : "direct" });
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
}
}
+4 -2
View File
@@ -41,7 +41,8 @@ export async function POST() {
const modelProbeTasks = new Map<string, Promise<Awaited<ReturnType<typeof probeModel>>>>();
for (const agent of agentList) {
const modelStr = agent.model || defaultModel;
const rawModel: any = agent.model;
const modelStr = typeof rawModel === "string" ? rawModel : (rawModel?.primary || defaultModel);
const { providerId, modelId } = parseModelRef(modelStr);
const key = `${providerId}/${modelId}`;
if (!modelProbeTasks.has(key)) {
@@ -58,7 +59,8 @@ export async function POST() {
}
const results = agentList.map((agent) => {
const modelStr = agent.model || defaultModel;
const rawModel: any = agent.model;
const modelStr = typeof rawModel === "string" ? rawModel : (rawModel?.primary || defaultModel);
const { providerId, modelId } = parseModelRef(modelStr);
const key = `${providerId}/${modelId}`;
const probe = modelProbeResults.get(key);
+433 -13
View File
@@ -20,6 +20,20 @@ interface HealthResult {
openclawVersion?: string;
}
interface LogResult {
ok: boolean;
issues: string[];
lastStallAt: string | null;
stallActive: boolean;
recentLines?: string[];
}
interface BackupEntry {
filename: string;
timestamp: string;
sizeBytes: number;
}
interface GatewayStatusProps {
compact?: boolean;
className?: string;
@@ -29,30 +43,177 @@ interface GatewayStatusProps {
export function GatewayStatus({ compact = false, className = "", hideIconOnMobile = false }: GatewayStatusProps) {
const { t } = useI18n();
const [health, setHealth] = useState<HealthResult | null>(null);
const [showError, setShowError] = useState(false);
const [logResult, setLogResult] = useState<LogResult | null>(null);
const [showDetail, setShowDetail] = useState(false);
const [showVersionTip, setShowVersionTip] = useState(false);
const [restarting, setRestarting] = useState(false);
const [restartMsg, setRestartMsg] = useState<string | null>(null);
const check = useCallback(() => {
// Config backup/restore state
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [restoring, setRestoring] = useState(false);
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
const [reloadCountdown, setReloadCountdown] = useState<number | null>(null);
const [showLogs, setShowLogs] = useState(false);
// Track consecutive failures to detect persistent config problems
const [consecutiveDownCount, setConsecutiveDownCount] = useState(0);
// Config change detection
const [configLastModified, setConfigLastModified] = useState<string | null>(null);
const [configPromptDismissed, setConfigPromptDismissed] = useState(false);
const fetchLogs = useCallback(() => {
fetch("/api/gateway-logs")
.then((r) => r.json())
.then((d: LogResult) => setLogResult(d))
.catch(() => {});
}, []);
const fetchBackups = useCallback(() => {
fetch("/api/config-backup")
.then((r) => r.json())
.then((d) => setBackups(d.backups || []))
.catch(() => setBackups([]));
}, []);
const fetchConfigMtime = useCallback(() => {
fetch("/api/config")
.then((r) => r.json())
.then((d) => { if (d.configLastModified) setConfigLastModified(d.configLastModified); })
.catch(() => {});
}, []);
const checkHealth = useCallback(() => {
fetch("/api/gateway-health")
.then((r) => r.json())
.then((d) => setHealth(d))
.catch(() => setHealth({ ok: false, error: t("gateway.fetchError") }));
}, [t]);
.then((d: HealthResult) => {
setHealth(d);
if (!d.ok) {
fetchLogs();
setConsecutiveDownCount((c) => {
// 第一次失敗就抓備份,讓使用者一開面板就能看到
if (c === 0) {
fetchBackups();
fetchConfigMtime();
}
return c + 1;
});
} else {
setConsecutiveDownCount(0);
setConfigPromptDismissed(false);
}
})
.catch(() => {
setHealth({ ok: false, error: t("gateway.fetchError") });
setConsecutiveDownCount((c) => c + 1);
});
}, [t, fetchLogs, fetchBackups, fetchConfigMtime]);
useEffect(() => {
check();
const timer = setInterval(check, 10000);
checkHealth();
const timer = setInterval(checkHealth, 10000);
return () => clearInterval(timer);
}, [check]);
}, [checkHealth]);
const handleDetailClick = useCallback(() => {
setShowDetail((v) => {
if (!v) {
// Opening panel — fetch fresh data
fetchLogs();
fetchBackups();
}
return !v;
});
}, [fetchLogs, fetchBackups]);
const handleRestart = useCallback(async () => {
if (restarting) return;
setRestarting(true);
setRestartMsg(null);
try {
const res = await fetch("/api/gateway-restart", { method: "POST" });
const data = await res.json();
if (data.ok) {
setRestartMsg("✅ 重啟指令已送出,稍後自動重新檢查…");
setTimeout(() => {
checkHealth();
setRestartMsg(null);
setShowDetail(false);
}, 4000);
} else {
setRestartMsg(`❌ 重啟失敗:${data.error || "未知錯誤"}`);
}
} catch (err: any) {
setRestartMsg(`❌ 重啟失敗:${err.message}`);
} finally {
setRestarting(false);
}
}, [restarting, checkHealth]);
const handleRestore = useCallback(async (filename: string) => {
if (restoring) return;
setRestoring(true);
setRestoreMsg(null);
try {
const res = await fetch("/api/config-backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
const data = await res.json();
if (data.success) {
setRestoreMsg(t("gateway.restoreSuccess"));
// Auto-restart gateway after restore, then countdown to reload
setTimeout(async () => {
await fetch("/api/gateway-restart", { method: "POST" }).catch(() => {});
// Start 5-second countdown
let count = 5;
setReloadCountdown(count);
const tick = setInterval(() => {
count -= 1;
if (count <= 0) {
clearInterval(tick);
window.location.reload();
} else {
setReloadCountdown(count);
}
}, 1000);
}, 500);
} else {
setRestoreMsg(`${t("gateway.restoreFailed")}${data.error || ""}`);
}
} catch (err: any) {
setRestoreMsg(`${t("gateway.restoreFailed")}${err.message}`);
} finally {
setRestoring(false);
}
}, [restoring, checkHealth, t]);
const gatewayTitle = health?.openclawVersion
? `OpenClaw ${health.openclawVersion}`
: "OpenClaw";
// Determine warning state: gateway alive but Telegram stalled
const telegramStall = health?.ok && logResult?.stallActive === true;
const showWarning = telegramStall;
// Show restart button when: down, or Telegram stalled
const showRestart = health !== null;
// 只要 gateway 下線且有備份,就顯示還原清單(不等 3 次失敗)
const showConfigHint = !health?.ok && backups.length > 0;
// Detect recent config change: modified within last 5 minutes
const configRecentlyChanged = (() => {
if (!configLastModified) return false;
const mtime = new Date(configLastModified).getTime();
return Date.now() - mtime < 5 * 60 * 1000;
})();
// 只要 gateway 下線 + config 近期有改動,就顯示醒目提示
const showConfigChangePrompt = !health?.ok && configRecentlyChanged && !configPromptDismissed;
return (
<div className={`relative inline-flex items-center gap-1.5 ${className}`.trim()}>
{/* Gateway link badge */}
<a
href={health?.ok && health.webUrl ? resolveGatewayUrl(health.webUrl) : undefined}
href={process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL ?? "/"}
target="_blank"
rel="noopener noreferrer"
title={gatewayTitle}
@@ -76,27 +237,286 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
) : "🦞 Gateway"}
<span className="opacity-50 text-[10px]"></span>
</a>
{showVersionTip && (
<div className="absolute top-full left-0 mt-1 z-50 px-2 py-1 rounded-md bg-black/80 border border-white/10 text-white text-[10px] whitespace-nowrap shadow-lg pointer-events-none">
{gatewayTitle}
</div>
)}
{/* Health indicator */}
{!health ? (
<span className={compact ? "text-[10px] text-[var(--text-muted)]" : "text-xs text-[var(--text-muted)]"}>--</span>
) : health.ok ? (
) : health.ok && !showWarning ? (
<span className={compact ? "text-green-400 text-xs cursor-help" : "text-green-400 text-sm cursor-help"} title={t("gateway.healthy")}></span>
) : showWarning ? (
<span
className={compact ? "text-yellow-400 text-xs cursor-pointer" : "text-yellow-400 text-sm cursor-pointer"}
title="Telegram 連線異常,建議重啟"
onClick={handleDetailClick}
></span>
) : (
<span
className={compact ? "text-red-400 text-xs cursor-pointer" : "text-red-400 text-sm cursor-pointer"}
title={health.error || t("gateway.unhealthy")}
onClick={() => setShowError((v) => !v)}
onClick={handleDetailClick}
></span>
)}
{showError && health && !health.ok && health.error && (
<div className="absolute top-full left-0 mt-1 z-50 px-3 py-2 rounded-lg bg-red-500/15 border border-red-500/30 text-red-300 text-xs max-w-64 whitespace-pre-wrap shadow-lg">
{/* Restart button — shown when there's a problem */}
{showRestart && (
<button
onClick={handleDetailClick}
className={`inline-flex items-center gap-1 rounded-full border font-medium transition-colors ${
compact ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs"
} bg-orange-500/20 text-orange-300 border-orange-500/40 hover:bg-orange-500/35 cursor-pointer`}
title="查看問題並重啟 Gateway"
>
🔄{!compact && " 重啟"}
</button>
)}
{/* Detail panel */}
{showDetail && (
<div className="absolute top-full left-0 mt-1 z-50 rounded-lg bg-[var(--card)] border border-[var(--border)] shadow-xl text-xs w-72 overflow-hidden">
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="font-semibold text-[var(--text)]">Gateway </span>
<button onClick={() => setShowDetail(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] cursor-pointer"></button>
</div>
<div className="px-3 py-2 space-y-2">
{/* Health status */}
<div className="flex items-center gap-2">
<span className="text-[var(--text-muted)]">Process</span>
<span className={health?.ok ? "text-green-400" : "text-red-400"}>
{health?.ok ? "✅ 運作中" : "❌ 無回應"}
</span>
</div>
{/* Telegram stall */}
{logResult && logResult.issues.includes("telegram_stall") && (
<div className="flex items-start gap-2">
<span className="text-[var(--text-muted)] shrink-0">Telegram</span>
<span className="text-yellow-400">
Polling
{logResult.lastStallAt && (
<span className="text-[var(--text-muted)] ml-1">
({new Date(logResult.lastStallAt).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" })})
</span>
)}
</span>
</div>
)}
{/* Subagent timeout */}
{logResult && logResult.issues.includes("subagent_timeout") && (
<div className="flex items-center gap-2">
<span className="text-[var(--text-muted)]">Subagent</span>
<span className="text-orange-400"> timeout </span>
</div>
)}
{/* Error message when down */}
{health && !health.ok && health.error && (
<div className="text-red-300 bg-red-500/10 rounded px-2 py-1.5 leading-relaxed">
{health.error}
</div>
)}
{/* Log toggle — always visible when gateway is down */}
{health && !health.ok && (
<button
onClick={() => { fetchLogs(); setShowLogs(v => !v); }}
className="w-full text-left px-2 py-1 rounded text-[11px] text-[var(--text-muted)] bg-white/5 hover:bg-white/10 border border-[var(--border)] transition-colors cursor-pointer"
>
{t("gateway.viewLogs")} {showLogs ? "▲" : "▼"}
</button>
)}
{/* Log viewer */}
{showLogs && (
<div className="rounded border border-[var(--border)] bg-black/30 px-2 py-2">
{logResult?.recentLines && logResult.recentLines.length > 0 ? (
<pre className="text-[9px] text-red-300/80 leading-relaxed overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{logResult.recentLines.join("\n")}
</pre>
) : (
<p className="text-[10px] text-[var(--text-muted)]"></p>
)}
</div>
)}
{/* Config change prompt — prominent banner when config recently changed */}
{showConfigChangePrompt && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 space-y-2">
<div className="flex items-start gap-2">
<span className="shrink-0 text-base"></span>
<div>
<div className="text-amber-300 font-semibold">{t("gateway.noResponse")}</div>
<div className="text-[var(--text-muted)] mt-1 leading-relaxed text-[11px]">{t("gateway.configChanged")}</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-1.5">
{(() => {
const recommended = findRecommendedBackup(backups);
return recommended ? (
<button
onClick={() => handleRestore(recommended.filename)}
disabled={restoring}
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40 hover:bg-amber-500/35 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{restoring ? t("gateway.restoring") : `${t("gateway.restorePrev")} (${formatBackupTime(recommended.timestamp)}, ${formatSize(recommended.sizeBytes)})`}
</button>
) : null;
})()}
<button
onClick={() => setConfigPromptDismissed(true)}
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-white/5 text-[var(--text-muted)] border border-[var(--border)] hover:bg-white/10 transition-colors cursor-pointer"
>
{t("gateway.dismiss")}
</button>
</div>
</div>
)}
{/* Config error hint + backup restore (when no recent change detected, or dismissed the prompt) */}
{showConfigHint && !showConfigChangePrompt && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 px-2 py-2 space-y-2">
<div className="flex items-start gap-1.5">
<span className="shrink-0">📋</span>
<div>
<div className="text-amber-300 font-medium">{t("gateway.configError")}</div>
<div className="text-[var(--text-muted)] mt-0.5 leading-relaxed">{t("gateway.configErrorDesc")}</div>
</div>
</div>
{/* Backup list */}
<div className="space-y-1">
<div className="text-[var(--text-muted)] text-[10px] uppercase tracking-wider">
{t("gateway.backupAvailable")} ({backups.length})
</div>
{backups.map((b) => {
const isRecommended = b.sizeBytes >= 1024;
const isSuspect = b.sizeBytes < 1024;
return (
<div key={b.filename} className={`flex items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors ${isRecommended ? "bg-emerald-500/10 hover:bg-emerald-500/15" : "bg-white/5 hover:bg-white/10"}`}>
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[11px] shrink-0 ${isSuspect ? "text-red-400/70 line-through" : "text-[var(--text)]"}`} title={b.filename}>
{formatBackupTime(b.timestamp)}
</span>
<span className={`text-[10px] shrink-0 ${isSuspect ? "text-red-400/60" : "text-[var(--text-muted)]"}`}>
{formatSize(b.sizeBytes)}
</span>
{isRecommended && (
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-medium shrink-0">{t("gateway.backupRecommended")}</span>
)}
{isSuspect && (
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/20 text-red-400 font-medium shrink-0">{t("gateway.backupSuspect")}</span>
)}
</div>
<button
onClick={() => handleRestore(b.filename)}
disabled={restoring}
className="shrink-0 px-2 py-0.5 rounded text-[10px] font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40 hover:bg-amber-500/35 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
>
{restoring ? t("gateway.restoring") : t("gateway.restoreBackup")}
</button>
</div>
);
})}
</div>
</div>
)}
{/* When gateway is down but no backups available */}
{!health?.ok && consecutiveDownCount >= 3 && backups.length === 0 && (
<div className="text-[var(--text-muted)] bg-white/5 rounded px-2 py-1.5 leading-relaxed text-[11px]">
📋 {t("gateway.noBackups")}
</div>
)}
{/* Restore result message */}
{restoreMsg && (
<div className={`rounded px-2 py-1.5 leading-relaxed ${
restoreMsg.startsWith("✅") ? "text-green-300 bg-green-500/10" : "text-red-300 bg-red-500/10"
}`}>
{restoreMsg}
</div>
)}
{/* Countdown to reload */}
{reloadCountdown !== null && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-emerald-300 font-semibold text-[11px]">
🔄 {reloadCountdown} {t("gateway.reloadCountdown")}
</span>
<button
onClick={() => window.location.reload()}
className="text-[10px] px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 hover:bg-emerald-500/35 transition-colors cursor-pointer"
>
{t("gateway.reloadNow")}
</button>
</div>
<div className="text-[10px] text-emerald-200/70 leading-relaxed">
{t("gateway.reloadHint")}
</div>
</div>
)}
{/* Restart result message */}
{restartMsg && (
<div className={`rounded px-2 py-1.5 leading-relaxed ${
restartMsg.startsWith("✅") ? "text-green-300 bg-green-500/10" : "text-red-300 bg-red-500/10"
}`}>
{restartMsg}
</div>
)}
</div>
{/* Restart button */}
<div className="px-3 py-2 border-t border-[var(--border)]">
<button
onClick={handleRestart}
disabled={restarting}
className="w-full py-1.5 rounded-lg bg-orange-500/20 text-orange-300 border border-orange-500/40 hover:bg-orange-500/35 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer font-medium"
>
{restarting ? "⏳ 重啟中…" : "🔄 重啟 Gateway"}
</button>
</div>
</div>
)}
</div>
);
}
/** Format backup timestamp for display: "3/15 08:30" */
function formatBackupTime(timestamp: string): string {
try {
const d = new Date(timestamp);
if (isNaN(d.getTime())) return timestamp;
const month = d.getMonth() + 1;
const day = d.getDate();
const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${month}/${day} ${hour}:${min}`;
} catch {
return timestamp;
}
}
/** Format file size for display */
function formatSize(bytes: number): string {
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
return bytes + " B";
}
/**
* Find the recommended backup: the latest one with size >= 1 KB.
* Small files (< 1024 bytes) are likely broken/empty configs.
*/
function findRecommendedBackup(backups: BackupEntry[]): BackupEntry | null {
return backups.find((b) => b.sizeBytes >= 1024) ?? null;
}
+3
View File
@@ -134,3 +134,6 @@ body {
border-top: 1px solid var(--border);
padding: 12px 16px;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+10 -2
View File
@@ -556,8 +556,16 @@ export default function Home() {
if (error && !data) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">{t("common.loadError")}: {error}</p>
<div className="min-h-screen flex flex-col items-center justify-center gap-6 p-8">
<div className="w-full max-w-md">
<GatewayStatus />
</div>
<div className="text-center">
<p className="text-red-400 text-sm">{t("common.loadError")}: {error}</p>
<p className="text-[var(--text-muted)] text-xs mt-1">
{t("gateway.configCorruptHint")}
</p>
</div>
</div>
);
}
+50 -23
View File
@@ -812,7 +812,8 @@ export default function PixelOfficePage() {
}
// Broadcast notification on meaningful state transitions
if (prev && prev !== agent.state) {
if (agent.state === 'working' && prev !== 'working') {
// Only show "上班了" when agent comes back from offline (not from idle)
if (agent.state === 'working' && prev === 'offline') {
const bid = Date.now() + Math.random()
setBroadcasts(b => [...b, { id: bid, emoji: agent.emoji, text: `${agent.emoji} ${agent.name} ${t('pixelOffice.broadcast.online')}` }])
setTimeout(() => setBroadcasts(b => b.filter(x => x.id !== bid)), 5000)
@@ -892,6 +893,40 @@ export default function PixelOfficePage() {
return () => clearInterval(interval)
}, [refreshGatewayHealthSnapshot])
// Debug helper: expose __pixelOffice on window for console testing
const debugCounterRef = useRef(0)
useEffect(() => {
;(window as any).__pixelOffice = {
addAgents(count = 1) {
const office = officeRef.current
if (!office) { console.warn('[pixelOffice] office not ready'); return }
for (let i = 0; i < count; i++) {
debugCounterRef.current++
const id = 9000 + debugCounterRef.current
office.addAgent(id, undefined, undefined, undefined, undefined, true)
}
console.log(`[pixelOffice] added ${count} agent(s), ids 9001${9000 + debugCounterRef.current}`)
},
clearDebug() {
const office = officeRef.current
if (!office) return
for (let i = 1; i <= debugCounterRef.current; i++) {
office.removeAgent(9000 + i)
}
debugCounterRef.current = 0
console.log('[pixelOffice] cleared debug agents')
},
list() {
const office = officeRef.current
if (!office) return
const rows: any[] = []
for (const [id, ch] of office.characters) rows.push({ id, state: ch.state, tile: `${ch.tileCol},${ch.tileRow}` })
console.table(rows)
},
}
return () => { delete (window as any).__pixelOffice }
}, []) // mount once only
useEffect(() => {
if (!selectedAgentId) return
try {
@@ -1180,12 +1215,8 @@ export default function PixelOfficePage() {
return tileX >= f.col && tileX < f.col + entry.footprintW &&
tileY >= f.row && tileY < f.row + entry.footprintH
})) {
// Click on PC — open gateway chat for main agent
const gw = gatewayRef.current
const sessionKey = 'agent:main:main'
let chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey }, gw.host)
if (gw.token) chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey, token: gw.token }, gw.host)
window.open(chatUrl, '_blank')
// Click on PC — navigate to dashboard settings
window.location.href = '/'
} else if (office.layout.furniture.some(f => {
if (f.uid !== 'library-r') return false
const entry = getCatalogEntry(f.type)
@@ -1658,6 +1689,8 @@ export default function PixelOfficePage() {
})
}
}
const stateOrder: Record<string, number> = { working: 0, waiting: 1, idle: 2, offline: 3 }
expanded.sort((a, b) => (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9))
return expanded
}, [agents])
@@ -1781,22 +1814,7 @@ export default function PixelOfficePage() {
</button>
</div>
</div>
<div className="md:hidden overflow-x-auto pb-1">
{displayAgents.length === 0 ? (
<div className="text-[var(--text-muted)] text-sm">{t('common.noData')}</div>
) : (
<div className="flex gap-2 min-w-full snap-x snap-mandatory">
{mobileAgentPages.map((page, pageIndex) => (
<div key={`mobile-agent-page-${pageIndex}`} className="grid grid-cols-3 grid-rows-3 gap-2 min-w-full h-[8.4rem] shrink-0 snap-start">
{page.map((agent) => renderAgentChip(agent, true))}
{page.length < 9 && Array.from({ length: 9 - page.length }).map((_, i) => (
<div key={`mobile-agent-page-${pageIndex}-placeholder-${i}`} className="rounded-lg border border-transparent" />
))}
</div>
))}
</div>
)}
</div>
{/* Mobile agent list moved to canvas overlay below */}
<div className="hidden md:flex gap-2 flex-1 flex-wrap">
{displayAgents.map((agent) => renderAgentChip(agent))}
{displayAgents.length === 0 && (
@@ -1825,6 +1843,15 @@ export default function PixelOfficePage() {
</div>
)}
{/* Mobile agent list overlay at bottom of canvas */}
{isMobileViewport && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-2 pb-1 pt-1 bg-gradient-to-t from-black/60 to-transparent pointer-events-none">
<div className="flex gap-1.5 overflow-x-auto no-scrollbar pointer-events-auto">
{displayAgents.map((agent) => renderAgentChip(agent, true))}
</div>
</div>
)}
{/* Broadcast notifications */}
{broadcasts.length > 0 && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 flex flex-col gap-2 pointer-events-none">
+7 -3
View File
@@ -515,14 +515,15 @@ export function Sidebar() {
🦞
</span>
<div className="min-w-0">
<div className="text-xs font-bold tracking-wide truncate">
OPENCLAW{mobileOpenclawVersion ? ` ${mobileOpenclawVersion}` : ""}
</div>
<div className="text-xs font-bold tracking-wide truncate">OPENCLAW</div>
<div className="text-[10px] text-[var(--text-muted)] truncate">
{pathname === "/" && mobileAgentCount !== null
? `${mobileAgentCount} ${t("home.agentCount")}`
: mobileCurrent ? t(mobileCurrent.labelKey) : "BOT DASHBOARD"}
</div>
{mobileOpenclawVersion && (
<div className="text-[9px] text-[var(--text-muted)] opacity-60 truncate">v{mobileOpenclawVersion}</div>
)}
</div>
</Link>
<div className="flex items-center gap-1">
@@ -695,6 +696,9 @@ export function Sidebar() {
<div>
<div className="text-sm font-bold text-[var(--text)] tracking-wide">OPENCLAW</div>
<div className="text-[10px] text-[var(--text-muted)] tracking-wider">BOT DASHBOARD</div>
{process.env.NEXT_PUBLIC_DASHBOARD_VERSION && (
<div className="text-[9px] text-[var(--text-muted)] opacity-60 tracking-wide">v{process.env.NEXT_PUBLIC_DASHBOARD_VERSION}</div>
)}
</div>
</Link>
<button
+235
View File
@@ -0,0 +1,235 @@
/**
* openclaw.json
*
*
* 1. SHA-256 hash
* 2.
* 3.
* 4.
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { OPENCLAW_HOME, OPENCLAW_CONFIG_PATH } from "./openclaw-paths";
// ── 常數 ────────────────────────────────────────────────
const BACKUP_DIR = path.join(OPENCLAW_HOME, "backups", "config");
const HASH_FILE = path.join(BACKUP_DIR, ".last-hash");
const MAX_ROLLING = 8; // 一般滾動備份保留數
const MIN_GOOD_SIZE = 1024; // 小於此 bytes 視為損毀,不計入錨點
// ── 持久化 hash(讀寫磁碟,重啟後仍有效)────────────────
function readPersistedHash(): string | null {
try {
const h = fs.readFileSync(HASH_FILE, "utf-8").trim();
return h.length === 64 ? h : null; // SHA-256 = 64 hex chars
} catch {
return null;
}
}
function writePersistedHash(hash: string): void {
try {
ensureBackupDir();
fs.writeFileSync(HASH_FILE, hash, "utf-8");
} catch { /* ignore */ }
}
// ── Hash ────────────────────────────────────────────────
export function computeHash(content: string): string {
return crypto.createHash("sha256").update(content).digest("hex");
}
// ── 備份目錄初始化 ──────────────────────────────────────
function ensureBackupDir(): void {
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
}
// ── 產生備份檔名 ────────────────────────────────────────
function makeBackupFilename(): string {
// openclaw.2026-03-15T08-30-00.json
const ts = new Date()
.toISOString()
.replace(/:/g, "-")
.replace(/\.\d+Z$/, "");
return `openclaw.${ts}.json`;
}
// ── 執行備份(將「目前磁碟上的版本」存到備份資料夾)──────
export function backupCurrentConfig(): { filename: string } | null {
try {
const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8");
ensureBackupDir();
const filename = makeBackupFilename();
const dest = path.join(BACKUP_DIR, filename);
fs.writeFileSync(dest, content, "utf-8");
pruneOldBackups();
return { filename };
} catch {
return null;
}
}
// ── 清理備份,保留策略:──────────────────────────────────
// - 昨天錨點:昨天最後一個正常備份(sizeBytes >= MIN_GOOD_SIZE
// - 上週錨點:2~7 天前最後一個正常備份
// - 滾動視窗:最新 MAX_ROLLING 個(不含上述兩個錨點)
function pruneOldBackups(): void {
try {
const files = listBackupFiles(); // 最新在前
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekAgoStart = todayStart - 7 * 86400000;
const toKeep = new Set<string>();
// 昨天錨點
const yesterdayAnchor = files.find((f) => {
const t = new Date(f.timestamp).getTime();
return t >= yesterdayStart && t < todayStart && f.sizeBytes >= MIN_GOOD_SIZE;
});
if (yesterdayAnchor) toKeep.add(yesterdayAnchor.filename);
// 上週錨點(2~7 天前)
const weekAnchor = files.find((f) => {
const t = new Date(f.timestamp).getTime();
return t >= weekAgoStart && t < yesterdayStart && f.sizeBytes >= MIN_GOOD_SIZE;
});
if (weekAnchor) toKeep.add(weekAnchor.filename);
// 滾動視窗:最新 MAX_ROLLING 個(錨點不佔名額)
let rollingCount = 0;
for (const f of files) {
if (toKeep.has(f.filename)) continue;
if (rollingCount < MAX_ROLLING) {
toKeep.add(f.filename);
rollingCount++;
}
}
// 刪除不在保留名單的備份
for (const f of files) {
if (!toKeep.has(f.filename)) {
try { fs.unlinkSync(path.join(BACKUP_DIR, f.filename)); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
// ── 列出所有備份 ────────────────────────────────────────
export interface BackupEntry {
filename: string;
timestamp: string; // ISO 格式
sizeBytes: number;
}
export function listBackupFiles(): BackupEntry[] {
try {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR)
.filter((f) => f.startsWith("openclaw.") && f.endsWith(".json"));
return files
.map((filename) => {
const stat = fs.statSync(path.join(BACKUP_DIR, filename));
// 從檔名解析時間戳:openclaw.2026-03-15T08-30-00.json
const tsMatch = filename.match(/^openclaw\.(.+)\.json$/);
const timestamp = tsMatch
? tsMatch[1].replace(/-(\d{2})-(\d{2})$/, ":$1:$2").replace(/T(\d{2})-/, "T$1:")
: stat.mtime.toISOString();
return { filename, timestamp, sizeBytes: stat.size };
})
.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // 最新在前
} catch {
return [];
}
}
// ── 從備份還原 ──────────────────────────────────────────
export interface RestoreResult {
success: boolean;
message: string;
restoredFrom?: string;
backedUpAs?: string;
}
export function restoreFromBackup(filename: string): RestoreResult {
const backupPath = path.join(BACKUP_DIR, filename);
// 安全檢查:防止 path traversal
if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
return { success: false, message: "Invalid filename" };
}
if (!fs.existsSync(backupPath)) {
return { success: false, message: `Backup not found: ${filename}` };
}
try {
// 讀取備份內容並驗證是否為合法 JSON
const backupContent = fs.readFileSync(backupPath, "utf-8");
JSON.parse(backupContent); // 驗證 JSON 格式
// 先備份當前版本(還原前的安全網)
const currentBackup = backupCurrentConfig();
// 執行還原
fs.writeFileSync(OPENCLAW_CONFIG_PATH, backupContent, "utf-8");
// 還原後持久化 hash,讓下次 polling 不會再觸發備份
writePersistedHash(computeHash(backupContent));
return {
success: true,
message: `Restored from ${filename}`,
restoredFrom: filename,
backedUpAs: currentBackup?.filename,
};
} catch (err: any) {
return { success: false, message: `Restore failed: ${err.message}` };
}
}
// ── 偵測變更並自動備份(在 /api/config GET 中呼叫)──────
export interface ChangeDetectionResult {
changed: boolean;
currentHash: string;
backedUp: boolean;
backupFilename?: string;
}
export function detectChangeAndBackup(rawContent: string): ChangeDetectionResult {
const currentHash = computeHash(rawContent);
const lastKnownHash = readPersistedHash();
// 第一次執行(無持久化記錄):記錄 hash,不觸發備份
if (lastKnownHash === null) {
writePersistedHash(currentHash);
return { changed: false, currentHash, backedUp: false };
}
// Hash 未變:無需備份
if (currentHash === lastKnownHash) {
return { changed: false, currentHash, backedUp: false };
}
// Hash 已變:備份目前版本,更新持久化 hash
const backup = backupCurrentConfig();
writePersistedHash(currentHash);
return {
changed: true,
currentHash,
backedUp: backup !== null,
backupFilename: backup?.filename,
};
}
// ── 取得備份目錄路徑(供外部使用)────────────────────────
export function getBackupDir(): string {
return BACKUP_DIR;
}
+71 -1
View File
@@ -281,6 +281,25 @@ const translations: Record<Locale, Record<string, string>> = {
"gateway.healthy": "Gateway 運作正常",
"gateway.unhealthy": "Gateway 異常",
"gateway.fetchError": "無法檢查 Gateway 狀態",
"gateway.noResponse": "Gateway 無回應",
"gateway.configChanged": "偵測到 openclaw.json 最近有變更,可能是設定錯誤導致。",
"gateway.configError": "設定檔可能有誤",
"gateway.configErrorDesc": "Gateway 無法啟動,可能是 openclaw.json 設定錯誤",
"gateway.restorePrev": "🔄 還原上一版設定",
"gateway.viewLogs": "📋 查看錯誤日誌",
"gateway.dismiss": "❌ 不處理",
"gateway.backupAvailable": "有可用備份",
"gateway.restoreBackup": "還原備份",
"gateway.restoring": "還原中…",
"gateway.restoreSuccess": "✅ 已還原,正在重啟 Gateway…",
"gateway.restoreFailed": "❌ 還原失敗",
"gateway.noBackups": "沒有可用的備份",
"gateway.backupRecommended": "建議",
"gateway.backupSuspect": "可能損毀",
"gateway.reloadCountdown": "秒後自動重新整理…",
"gateway.reloadNow": "立即重新整理",
"gateway.reloadHint": "重新整理後,可點選機器人卡片上的「測試」確認是否正常運作",
"gateway.configCorruptHint": "設定檔可能損毀,請使用上方 Gateway 面板還原備份",
// pixel office
"pixelOffice.title": "OpenClaw Agents 辦公室",
@@ -293,7 +312,7 @@ const translations: Record<Locale, Record<string, string>> = {
"pixelOffice.sound": "音效",
"pixelOffice.resetView": "重設視圖",
"pixelOffice.state.working": "工作中",
"pixelOffice.state.idle": "摸魚中",
"pixelOffice.state.idle": "休息中",
"pixelOffice.state.offline": "下班了",
"pixelOffice.state.waiting": "等待中",
"pixelOffice.tempWorker": "臨時工",
@@ -607,6 +626,25 @@ const translations: Record<Locale, Record<string, string>> = {
"gateway.healthy": "Gateway 运行正常",
"gateway.unhealthy": "Gateway 异常",
"gateway.fetchError": "无法检查 Gateway 状态",
"gateway.noResponse": "Gateway 无响应",
"gateway.configChanged": "检测到 openclaw.json 最近有变更,可能是配置错误导致。",
"gateway.configError": "配置文件可能有误",
"gateway.configErrorDesc": "Gateway 无法启动,可能是 openclaw.json 配置错误",
"gateway.restorePrev": "🔄 还原上一版配置",
"gateway.viewLogs": "📋 查看错误日志",
"gateway.dismiss": "❌ 不处理",
"gateway.backupAvailable": "有可用备份",
"gateway.restoreBackup": "还原备份",
"gateway.restoring": "还原中…",
"gateway.restoreSuccess": "✅ 已还原,正在重启 Gateway…",
"gateway.restoreFailed": "❌ 还原失败",
"gateway.noBackups": "没有可用的备份",
"gateway.backupRecommended": "建议",
"gateway.backupSuspect": "可能损坏",
"gateway.reloadCountdown": "秒后自动刷新…",
"gateway.reloadNow": "立即刷新",
"gateway.reloadHint": "刷新后,可点击机器人卡片上的「测试」确认是否正常运作",
"gateway.configCorruptHint": "配置文件可能损坏,请使用上方 Gateway 面板还原备份",
// pixel office
"pixelOffice.title": "OpenClaw Agents办公室",
@@ -937,6 +975,25 @@ const translations: Record<Locale, Record<string, string>> = {
"gateway.healthy": "Gateway is running",
"gateway.unhealthy": "Gateway is down",
"gateway.fetchError": "Cannot check Gateway status",
"gateway.noResponse": "Gateway is not responding",
"gateway.configChanged": "openclaw.json was recently modified. This may be caused by a config error.",
"gateway.configError": "Config file may have errors",
"gateway.configErrorDesc": "Gateway failed to start, possibly due to openclaw.json config errors",
"gateway.restorePrev": "🔄 Restore previous config",
"gateway.viewLogs": "📋 View error logs",
"gateway.dismiss": "❌ Dismiss",
"gateway.backupAvailable": "Backup available",
"gateway.restoreBackup": "Restore backup",
"gateway.restoring": "Restoring…",
"gateway.restoreSuccess": "✅ Restored, restarting Gateway…",
"gateway.restoreFailed": "❌ Restore failed",
"gateway.noBackups": "No backups available",
"gateway.backupRecommended": "Recommended",
"gateway.backupSuspect": "Possibly corrupt",
"gateway.reloadCountdown": "s until auto-refresh…",
"gateway.reloadNow": "Refresh now",
"gateway.reloadHint": "After refresh, click the Test button on each bot card to verify it's working.",
"gateway.configCorruptHint": "Config may be corrupt. Use the Gateway panel above to restore a backup.",
// pixel office
"pixelOffice.title": "OpenClaw Agents Office",
@@ -1283,6 +1340,17 @@ const I18nContext = createContext<I18nContextType>({
t: (key) => key,
});
function detectBrowserLocale(): Locale {
const langs = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const lang of langs) {
const l = lang.toLowerCase();
if (l.startsWith('zh-tw') || l.startsWith('zh-hant') || l.startsWith('zh-hk') || l.startsWith('zh-mo')) return 'zh-TW';
if (l.startsWith('zh')) return 'zh';
if (l.startsWith('en')) return 'en';
}
return 'zh';
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("vi");
@@ -1290,6 +1358,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const saved = localStorage.getItem("locale") as Locale;
if (saved && (saved === "vi" || saved === "zh-TW" || saved === "zh" || saved === "en")) {
setLocaleState(saved);
} else {
setLocaleState(detectBrowserLocale());
}
}, []);
+51 -5
View File
@@ -1,5 +1,5 @@
import path from "path";
import { exec, execFile } from "child_process";
import { exec, execFile, execSync } from "child_process";
import { promisify } from "util";
import { readJsonFileSync } from "@/lib/json";
import { OPENCLAW_HOME } from "@/lib/openclaw-paths";
@@ -64,17 +64,36 @@ function quoteShellArg(arg: string): string {
return `"${arg.replace(/"/g, '""')}"`;
}
const EXTRA_PATH =
process.platform === "win32"
? "%PATH%;%APPDATA%\\npm;%LOCALAPPDATA%\\Programs\\openclaw"
: `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
let _openclawPath: string | null | undefined = undefined;
function findOpenclawPath(): string {
if (_openclawPath !== undefined) return _openclawPath ?? "openclaw";
try {
const cmd = process.platform === "win32" ? "where openclaw" : "which openclaw";
const env = { ...process.env, PATH: EXTRA_PATH };
_openclawPath = execSync(cmd, { encoding: "utf8", env }).trim().split("\n")[0].trim();
} catch {
_openclawPath = null;
}
return _openclawPath ?? "openclaw";
}
async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
const env = { ...process.env, FORCE_COLOR: "0", PATH: EXTRA_PATH };
const bin = findOpenclawPath();
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
return execFileAsync(bin, args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
}
const command = `openclaw ${args.map(quoteShellArg).join(" ")}`;
const command = `${quoteShellArg(bin)} ${args.map(quoteShellArg).join(" ")}`;
return execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
env,
@@ -189,7 +208,8 @@ function extractErrorMessage(payload: any, fallback: string): string {
async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeResult | null> {
const providerCfg = loadProviderConfig(params.providerId);
if (!providerCfg?.baseUrl || !providerCfg.api || !providerCfg.apiKey) return null;
if (!providerCfg?.baseUrl || !providerCfg.apiKey) return null;
if (!providerCfg.api) providerCfg.api = "openai-completions";
const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS;
// Kimi and MiniMax providers require temperature > 0
@@ -254,6 +274,32 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
}
}
if (providerCfg.api === "ollama") {
const url = `${providerCfg.baseUrl.replace(/\/+$/, "")}/v1/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" };
}
}
if (providerCfg.api === "openai-completions") {
const url = `${providerCfg.baseUrl.replace(/\/+$/, "")}/chat/completions`;
const body = {
+23 -4
View File
@@ -1,4 +1,4 @@
import { exec, execFile } from "child_process";
import { exec, execFile, execSync } from "child_process";
import crypto from "crypto";
import { promisify } from "util";
@@ -10,17 +10,36 @@ function quoteShellArg(arg: string): string {
return `"${arg.replace(/"/g, '""')}"`;
}
const EXTRA_PATH =
process.platform === "win32"
? "%PATH%;%APPDATA%\\npm;%LOCALAPPDATA%\\Programs\\openclaw"
: `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
let _openclawPath: string | null | undefined = undefined;
function findOpenclawPath(): string {
if (_openclawPath !== undefined) return _openclawPath ?? "openclaw";
try {
const cmd = process.platform === "win32" ? "where openclaw" : "which openclaw";
const env = { ...process.env, PATH: EXTRA_PATH };
_openclawPath = execSync(cmd, { encoding: "utf8", env }).trim().split("\n")[0].trim();
} catch {
_openclawPath = null;
}
return _openclawPath ?? "openclaw";
}
export async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
const env = { ...process.env, FORCE_COLOR: "0" };
const env = { ...process.env, FORCE_COLOR: "0", PATH: EXTRA_PATH };
const bin = findOpenclawPath();
if (process.platform !== "win32") {
return execFileAsync("openclaw", args, {
return execFileAsync(bin, args, {
maxBuffer: 10 * 1024 * 1024,
env,
});
}
const command = `openclaw ${args.map(quoteShellArg).join(" ")}`;
const command = `${quoteShellArg(bin)} ${args.map(quoteShellArg).join(" ")}`;
return execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
env,
+6 -3
View File
@@ -17,6 +17,7 @@ export interface AgentActivity {
toolStatus?: string
lastActive: number
subagents?: SubagentInfo[]
lastTask?: string
}
/** Track which subagent keys were active last sync, per parent agent */
@@ -64,10 +65,10 @@ export function syncAgentsToOffice(
if (charId === undefined) {
charId = nextIdRef.current++
agentIdMap.set(activity.agentId, charId)
// Spawn at door if agent was previously offline or is brand new
// 只有從 offline 恢復時才從門口走進來;
// 頁面初始載入(isNew)時直接放到座位,避免讓使用者以為角色剛去摸魚回來
const wasOffline = prevAgentStates.get(activity.agentId) === 'offline'
const isNew = !prevAgentStates.has(activity.agentId)
office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline || isNew)
office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline)
}
// Set label, avoiding duplicated values like "main (main)"
@@ -83,10 +84,12 @@ export function syncAgentsToOffice(
case 'working':
office.setAgentActive(charId, true)
office.setAgentTool(charId, activity.currentTool || null)
office.setAgentTaskText(charId, activity.lastTask)
break
case 'idle':
office.setAgentActive(charId, false)
office.setAgentTool(charId, null)
office.setAgentTaskText(charId, undefined)
break
case 'waiting':
office.setAgentActive(charId, true)
+40 -2
View File
@@ -1,4 +1,5 @@
import { CharacterState, Direction, TILE_SIZE } from '../types'
import { matrixEffectSeeds } from './matrixEffect'
import type { Character, Seat, SpriteData, TileType as TileTypeVal } from '../types'
import type { CharacterSprites } from '../sprites/spriteData'
import { findPath } from '../layout/tileMap'
@@ -89,6 +90,7 @@ export function createCharacter(
seatTimer: 0,
isSubagent: false,
parentAgentId: null,
greetLocked: false,
label: '',
matrixEffect: null,
matrixEffectTimer: 0,
@@ -101,6 +103,8 @@ export function createCharacter(
codeSnippets: [],
photoComments: [],
isViewingPhoto: false,
yieldTimer: 0,
yieldDestination: null,
}
}
@@ -145,6 +149,27 @@ export function updateCharacter(
}
case CharacterState.IDLE: {
// Under greeting control — stay frozen
if (ch.greetLocked) break
// Pending walk-back before despawn — start walking immediately
if (ch.pendingDespawn && ch.pendingDespawn !== true) {
const target = ch.pendingDespawn
const path = findPath(ch.tileCol, ch.tileRow, target.col, target.row, tileMap, blockedTiles)
if (path.length > 0) {
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
} else {
// Can't reach target — despawn in place
ch.pendingDespawn = undefined
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
break
}
// No idle animation — static pose
ch.frame = 0
if (ch.seatTimer < 0) ch.seatTimer = 0 // clear turn-end sentinel
@@ -240,6 +265,19 @@ export function updateCharacter(
ch.x = center.x
ch.y = center.y
// Temp worker returning to seat before exit — despawn on arrival
if (ch.pendingDespawn) {
const target = ch.pendingDespawn === true ? null : ch.pendingDespawn
const arrived = !target || (ch.tileCol === target.col && ch.tileRow === target.row)
if (arrived) {
ch.pendingDespawn = undefined
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
break
}
}
if (ch.isActive) {
if (!ch.seatId) {
// No seat — type in place
@@ -330,8 +368,8 @@ export function updateCharacter(
ch.moveProgress = 0
}
// If became active while wandering, repath to seat
if (ch.isActive && ch.seatId) {
// If became active while wandering, repath to seat (skip if under greeting control or yielding)
if (ch.isActive && ch.seatId && !ch.greetLocked && !ch.yieldDestination) {
const seat = seats.get(ch.seatId)
if (seat) {
const lastStep = ch.path[ch.path.length - 1]
+794 -24
View File
@@ -13,6 +13,7 @@ import {
CHARACTER_HIT_HEIGHT,
} from '../constants'
import type { Character, Seat, FurnitureInstance, TileType as TileTypeVal, OfficeLayout, PlacedFurniture } from '../types'
import { FurnitureType } from '../types'
import { createCharacter, updateCharacter } from './characters'
import { CHARACTER_PALETTES, getAvailableCharacterVariantCount } from '../sprites/spriteData'
import { matrixEffectSeeds } from './matrixEffect'
@@ -86,7 +87,7 @@ const SRE_BLACKWORDS_ZH_TW = [
'先查日誌紀錄',
'先重現',
'限流先開',
'還好',
'檢查gateway',
'降載執行',
'先做降級',
'先擴容',
@@ -368,12 +369,11 @@ const GATEWAY_SRE_LABEL = '值班SRE'
const GATEWAY_SRE_STANDBY_COL = 2
const GATEWAY_SRE_STANDBY_ROW = 14
const GATEWAY_SRE_RESCUE_CANDIDATES = [
// Break-area server sits at left wall (around col 1~2, row 12~13).
// "Front of server" means the lower edge of the rack in this top-down view.
{ col: 2, row: 14 },
{ col: 1, row: 14 },
{ col: 3, row: 13 },
{ col: 3, row: 12 },
// Rescue point: right wall of lounge area.
{ col: 18, row: 14 },
{ col: 18, row: 13 },
{ col: 17, row: 14 },
{ col: 17, row: 13 },
] as const
export type GatewaySreState = 'unknown' | 'healthy' | 'degraded' | 'down'
@@ -386,6 +386,23 @@ export interface GatewaySreInfo {
checkedAt: number | null
}
interface GreetingSequence {
childId: number
parentId: number
childTarget: { col: number; row: number } | null
/** Where the parent walks to when they are NOT at their seat (midpoint meeting) */
parentTarget: { col: number; row: number } | null
/** Tile to wait at when MainAgent is busy with another greeter */
waitTarget: { col: number; row: number } | null
phase: 'waiting' | 'walk' | 'pause' | 'parent_smile' | 'child_smile' | 'final_pause' | 'complete'
timer: number
isExit: boolean
/** Tile to walk back to after farewell, before despawning */
exitReturnPos: { col: number; row: number } | null
/** True when child arrived at greeting tile but MainAgent wasn't physically present */
parentAbsent?: boolean
}
export class OfficeState {
layout: OfficeLayout
tileMap: TileTypeVal[][]
@@ -417,6 +434,19 @@ export class OfficeState {
private gatewaySreResponseMs: number | null = null
private gatewaySreCheckedAt: number | null = null
private locale: OfficeLocale = 'zh'
private activeGreetings: Map<number, GreetingSequence> = new Map()
/** The first regular agent added — all other agents greet this one on entry */
private mainAgentId: number | null = null
/** FIFO queue of agent IDs waiting to greet MainAgent. Only queue head walks over; others stay at seat. */
private greetQueue: number[] = []
/** Subagents lingering at seat before farewell. key=charId, value=remaining seconds */
private lingerSubagents: Map<number, number> = new Map()
/** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */
private exitOnGreetComplete: Set<number> = new Set()
/** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */
private explicitlyIdledAgents: Set<number> = new Set()
/** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */
private exitReturnStash: Map<number, { col: number; row: number }> = new Map()
getTempWorkerLabel(): string {
return getTempWorkerLabel(this.locale)
@@ -709,6 +739,13 @@ export class OfficeState {
ch.matrixEffectSeeds = matrixEffectSeeds()
}
this.characters.set(id, ch)
// Track first agent as MainAgent; others greet on entry
if (this.mainAgentId === null) {
this.mainAgentId = id
} else if (spawnAtDoor) {
this.tryStartGreeting(ch)
}
}
/** Spawn the office cat at a random walkable tile */
@@ -848,6 +885,333 @@ export class OfficeState {
}
}
/** Find a walkable tile adjacent to a character */
private findAdjacentWalkable(ch: Character): { col: number; row: number } | null {
const adjacents = [
{ col: ch.tileCol - 1, row: ch.tileRow },
{ col: ch.tileCol + 1, row: ch.tileRow },
{ col: ch.tileCol, row: ch.tileRow - 1 },
{ col: ch.tileCol, row: ch.tileRow + 1 },
]
for (const t of adjacents) {
if (isWalkable(t.col, t.row, this.tileMap, this.blockedTiles)) return t
}
return null
}
/**
* Find a midpoint meeting location when MainAgent is not at their seat.
* Returns childTarget (where child walks) and parentTarget (where parent walks),
* adjacent to each other near the midpoint between the two characters.
*/
private findMeetingTiles(
child: Character,
parent: Character,
): { childTarget: { col: number; row: number }; parentTarget: { col: number; row: number } } | null {
const midCol = Math.round((child.tileCol + parent.tileCol) / 2)
const midRow = Math.round((child.tileRow + parent.tileRow) / 2)
// Find walkable tile closest to midpoint for the parent to walk to
const parentTile = this.findClosestWalkable(midCol, midRow)
if (!parentTile) return null
// Verify parent can reach that tile
const parentPath = findPath(parent.tileCol, parent.tileRow, parentTile.col, parentTile.row, this.tileMap, this.blockedTiles)
if (parentPath.length === 0 && !(parent.tileCol === parentTile.col && parent.tileRow === parentTile.row)) return null
// Child walks to a tile adjacent to parent's meeting tile
const adjacents = [
{ col: parentTile.col - 1, row: parentTile.row },
{ col: parentTile.col + 1, row: parentTile.row },
{ col: parentTile.col, row: parentTile.row - 1 },
{ col: parentTile.col, row: parentTile.row + 1 },
]
for (const adj of adjacents) {
if (!isWalkable(adj.col, adj.row, this.tileMap, this.blockedTiles)) continue
const childPath = findPath(child.tileCol, child.tileRow, adj.col, adj.row, this.tileMap, this.blockedTiles)
if (childPath.length > 0) return { childTarget: adj, parentTarget: parentTile }
}
return null
}
/** Returns true if MainAgent is currently in an active greeting sequence */
private isMainAgentBusy(): boolean {
if (this.mainAgentId === null) return false
for (const g of this.activeGreetings.values()) {
if (g.parentId === this.mainAgentId) return true
}
return false
}
/**
* Queue `ch` to greet MainAgent. If MainAgent is free and queue is empty,
* starts the greeting immediately. Otherwise, adds to FIFO queue and the agent
* stays at their seat until it's their turn.
* No-op if: ch IS MainAgent, already in queue/greeting, MainAgent not found.
*/
private tryStartGreeting(ch: Character): void {
if (this.mainAgentId === null || ch.id === this.mainAgentId || ch.isSystemRole) return
if (this.activeGreetings.has(ch.id)) return
if (this.greetQueue.includes(ch.id)) return
const mainCh = this.characters.get(this.mainAgentId)
if (!mainCh || mainCh.matrixEffect === 'despawn') return
this.greetQueue.push(ch.id)
this.processGreetQueue()
}
/** Dequeue the next agent and start their greeting walk if MainAgent is free. */
private processGreetQueue(): void {
if (this.isMainAgentBusy()) return
// Find first queued agent that still exists and isn't already greeting
while (this.greetQueue.length > 0) {
const nextId = this.greetQueue[0]
const ch = this.characters.get(nextId)
if (!ch || this.activeGreetings.has(nextId)) {
this.greetQueue.shift()
continue
}
const mainCh = this.characters.get(this.mainAgentId!)
if (!mainCh || mainCh.matrixEffect === 'despawn') {
this.greetQueue.length = 0
return
}
// Determine meeting point based on whether MainAgent is at their seat
let greetTile: { col: number; row: number } | null
let parentTarget: { col: number; row: number } | null = null
if (mainCh.state === CharacterState.TYPE) {
// MainAgent is at seat — child walks to adjacent tile, parent stays
greetTile = this.findAdjacentWalkable(mainCh)
} else {
// MainAgent is not at seat — meet halfway
const meeting = this.findMeetingTiles(ch, mainCh)
if (meeting) {
greetTile = meeting.childTarget
parentTarget = meeting.parentTarget
} else {
// Fallback: walk to wherever MainAgent currently is
greetTile = this.findAdjacentWalkable(mainCh)
}
}
if (!greetTile) { this.greetQueue.shift(); continue }
const greetPath = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
if (greetPath.length === 0) { this.greetQueue.shift(); continue }
this.greetQueue.shift()
ch.path = greetPath
ch.state = CharacterState.WALK
ch.moveProgress = 0
ch.greetLocked = true
// If parent needs to walk to meeting point, lock and start them moving now
if (parentTarget) {
const parentPath = findPath(mainCh.tileCol, mainCh.tileRow, parentTarget.col, parentTarget.row, this.tileMap, this.blockedTiles)
if (parentPath.length > 0) {
mainCh.greetLocked = true
mainCh.path = parentPath
mainCh.state = CharacterState.WALK
mainCh.moveProgress = 0
} else {
parentTarget = null // parent already there or unreachable
}
}
const isExitGreeting = this.exitOnGreetComplete.has(ch.id)
if (isExitGreeting) this.exitOnGreetComplete.delete(ch.id)
// Recover the stashed return position for this exit greeting
const exitReturnPos = isExitGreeting ? (this.exitReturnStash.get(ch.id) ?? null) : null
if (isExitGreeting) this.exitReturnStash.delete(ch.id)
this.activeGreetings.set(ch.id, {
childId: ch.id,
parentId: this.mainAgentId!,
childTarget: greetTile,
parentTarget,
waitTarget: null,
phase: 'walk',
timer: 0,
isExit: isExitGreeting,
exitReturnPos,
})
return
}
}
/** Approximate facing direction from one tile toward another */
private directionToward(fromCol: number, fromRow: number, toCol: number, toRow: number): Direction {
const dc = toCol - fromCol
const dr = toRow - fromRow
if (Math.abs(dc) >= Math.abs(dr)) return dc >= 0 ? Direction.RIGHT : Direction.LEFT
return dr >= 0 ? Direction.DOWN : Direction.UP
}
private updateGreetings(dt: number): void {
const completed: number[] = []
for (const [childId, seq] of this.activeGreetings) {
const child = this.characters.get(childId)
const parent = this.characters.get(seq.parentId)
if (!child || child.matrixEffect === 'despawn') {
completed.push(childId)
if (parent) parent.greetLocked = false
continue
}
switch (seq.phase) {
case 'walk': {
const target = seq.childTarget
if (!target) { seq.phase = 'complete'; break }
// If parent has a meeting target, keep them walking toward it
if (seq.parentTarget && parent) {
const pt = seq.parentTarget
const parentArrived = parent.tileCol === pt.col && parent.tileRow === pt.row
if (!parentArrived && parent.path.length === 0 && parent.state !== CharacterState.WALK) {
const repath = findPath(parent.tileCol, parent.tileRow, pt.col, pt.row, this.tileMap, this.blockedTiles)
if (repath.length > 0) {
parent.path = repath
parent.state = CharacterState.WALK
parent.moveProgress = 0
parent.greetLocked = true
}
}
}
const childArrived = child.tileCol === target.col && child.tileRow === target.row
const parentArrived = !seq.parentTarget || !parent ||
(parent.tileCol === seq.parentTarget.col && parent.tileRow === seq.parentTarget.row)
// Both have arrived at their respective meeting tiles
if (childArrived && parentArrived) {
child.path = []
child.state = CharacterState.IDLE
child.greetLocked = true
if (parent) {
parent.greetLocked = true
parent.path = []
parent.state = CharacterState.IDLE
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
// Detect if parent is not physically nearby (wandered away from greeting tile)
const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow)
if (seq.isExit && distToParent > 3) {
seq.parentAbsent = true
}
} else {
// Parent character doesn't exist
if (seq.isExit) seq.parentAbsent = true
}
seq.phase = 'pause'
seq.timer = 1.0
} else if (child.path.length === 0 && child.state !== CharacterState.WALK) {
// Child lost path mid-walk — try to re-path to target
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
if (path.length > 0) {
child.path = path
child.state = CharacterState.WALK
child.moveProgress = 0
} else {
seq.phase = 'complete'
}
}
break
}
case 'pause': {
child.path = []
child.state = CharacterState.IDLE
if (parent) {
parent.path = []
parent.state = CharacterState.IDLE
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
}
seq.timer -= dt
if (seq.timer <= 0) {
const emoji = seq.isExit ? '❤️' : '😊'
if (parent) this.pushCodeSnippet(parent.id, emoji)
seq.phase = 'parent_smile'
seq.timer = 1.0
}
break
}
case 'parent_smile': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
this.pushCodeSnippet(child.id, seq.isExit ? '❤️' : '😊')
seq.phase = 'child_smile'
seq.timer = 1.0
}
break
}
case 'child_smile': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
seq.phase = 'final_pause'
seq.timer = 1.0
}
break
}
case 'final_pause': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
seq.phase = 'complete'
}
break
}
case 'complete': {
child.greetLocked = false
if (parent) parent.greetLocked = false
if (seq.isExit) {
child.bubbleType = null
// After farewell (whether MainAgent was present or not), walk to bottom-right corner
if (!this.walkToBottomRightThenDespawn(child)) {
child.matrixEffect = 'despawn'
child.matrixEffectTimer = 0
child.matrixEffectSeeds = matrixEffectSeeds()
}
} else {
// Walk to assigned seat
if (child.seatId) {
const seat = this.seats.get(child.seatId)
if (seat) {
const path = this.withOwnSeatUnblocked(child, () =>
findPath(child.tileCol, child.tileRow, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
child.path = path
child.state = CharacterState.WALK
child.moveProgress = 0
}
}
}
}
completed.push(childId)
break
}
}
}
for (const id of completed) this.activeGreetings.delete(id)
// After any greeting completes, let the next queued agent proceed
if (completed.length > 0) this.processGreetQueue()
}
private findClosestWalkable(targetCol: number, targetRow: number): { col: number; row: number } {
if (this.walkableTiles.length === 0) return { col: 1, row: 1 }
let best = this.walkableTiles[0]
@@ -863,6 +1227,101 @@ export class OfficeState {
return best
}
/** Find a walkable tile near the sofa/lounge area, for temp workers with no assigned seat */
private findSofaAreaTile(): { col: number; row: number } | null {
const sofa = this.layout.furniture.find(
(f) => f.type === FurnitureType.SOFA || f.type === FurnitureType.BENCH,
)
if (!sofa) return null
return this.findClosestWalkable(sofa.col, sofa.row)
}
/** Find the walkable tile closest to the bottom-right corner of the map */
private findBottomRightCornerTile(): { col: number; row: number } | null {
if (this.walkableTiles.length === 0) return null
// Bottom-right in tile space = max col + max row
const maxCol = Math.max(...this.walkableTiles.map((t) => t.col))
const maxRow = Math.max(...this.walkableTiles.map((t) => t.row))
return this.findClosestWalkable(maxCol, maxRow)
}
/**
* Walk character to the bottom-right corner then despawn.
* Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move.
*/
private walkToBottomRightThenDespawn(ch: Character): boolean {
const corner = this.findBottomRightCornerTile()
if (!corner) return false
if (corner.col === ch.tileCol && corner.row === ch.tileRow) {
// Already there — despawn directly
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
return true
}
const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles)
if (path.length === 0) return false
ch.path = path
ch.state = CharacterState.WALK
ch.moveProgress = 0
ch.pendingDespawn = corner
return true
}
/**
* Find a walk-back path for a departing temp worker.
* Ensures the path is long enough that the worker visibly walks away from MainAgent.
* Falls back to the sofa area or any distant tile if the primary target is unreachable.
*/
private findExitWalkPath(
child: Character,
preferredTarget: { col: number; row: number } | null,
): { path: Array<{ col: number; row: number }>; target: { col: number; row: number } } | null {
const MIN_WALK_TILES = 5 // must walk at least this many steps
const tryTarget = (target: { col: number; row: number }) => {
if (target.col === child.tileCol && target.row === child.tileRow) return null
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
if (path.length >= MIN_WALK_TILES) return { path, target }
return null
}
// 1. Try preferred target (seat area)
if (preferredTarget) {
const result = tryTarget(preferredTarget)
if (result) return result
}
// 2. Try sofa area
const sofaTile = this.findSofaAreaTile()
if (sofaTile) {
const result = tryTarget(sofaTile)
if (result) return result
}
// 3. Find any walkable tile sufficiently far from current position
const { tileCol: cx, tileRow: cy } = child
const farTile = this.walkableTiles
.filter((t) => Math.abs(t.col - child.tileCol) + Math.abs(t.row - child.tileRow) >= MIN_WALK_TILES)
.sort((a, b) => {
// Prefer tiles far from child but in direction away from center of map
const da = Math.abs(a.col - cx) + Math.abs(a.row - cy)
const db = Math.abs(b.col - cx) + Math.abs(b.row - cy)
return db - da
})
.find((t) => {
const path = findPath(child.tileCol, child.tileRow, t.col, t.row, this.tileMap, this.blockedTiles)
return path.length >= MIN_WALK_TILES
})
if (farTile) {
const path = findPath(child.tileCol, child.tileRow, farTile.col, farTile.row, this.tileMap, this.blockedTiles)
return { path, target: farTile }
}
return null
}
private getGatewaySrePatrolTiles(): Array<{ col: number; row: number }> {
// Mostly patrol in lounge (break area), occasionally stroll in office areas.
const loungeTiles = this.walkableTiles.filter((t) => t.row >= 9)
@@ -881,7 +1340,7 @@ export class OfficeState {
return { col: candidate.col, row: candidate.row }
}
}
return this.findClosestWalkable(2, 14)
return this.findClosestWalkable(18, 14)
}
private getGatewaySreDegradedTiles(
@@ -1287,6 +1746,9 @@ export class OfficeState {
ch.matrixEffectSeeds = matrixEffectSeeds()
this.characters.set(id, ch)
// Join MainAgent greeting queue (waits in place if MainAgent is busy)
this.tryStartGreeting(ch)
this.subagentIdMap.set(key, id)
this.subagentMeta.set(id, { parentAgentId, parentToolId })
return id
@@ -1301,26 +1763,67 @@ export class OfficeState {
const ch = this.characters.get(id)
if (ch) {
if (ch.matrixEffect === 'despawn') {
// Already despawning — just clean up maps
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
this.lingerSubagents.delete(id)
return
}
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
}
// Start despawn animation — keep character in map for rendering
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
ch.bubbleType = null
}
// Clean up tracking maps immediately so keys don't collide
// If already lingering, do nothing — let the timer run
if (this.lingerSubagents.has(id)) return
// Start linger: stay at seat for 60s, then do farewell
this.lingerSubagents.set(id, 60)
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
return
}
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
}
/** Internal: execute the actual farewell sequence for a subagent (called after linger) */
private startSubagentFarewell(id: number): void {
const ch = this.characters.get(id)
if (!ch || ch.matrixEffect === 'despawn') {
this.characters.delete(id)
return
}
// Save a walkable return position before freeing seat.
// Seat tile itself is often blocked by chair furniture, so use nearest walkable floor tile.
let exitReturnPos: { col: number; row: number } | null = null
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) {
exitReturnPos = this.findClosestWalkable(Math.round(seat.seatCol), Math.round(seat.seatRow))
seat.assigned = false
}
ch.seatId = null
}
// No seat — fall back to sofa/lounge area
if (!exitReturnPos) exitReturnPos = this.findSofaAreaTile()
ch.bubbleType = null
// Queue farewell greeting with MainAgent (isExit=true marks it as a departure)
this.tryStartGreeting(ch)
if (this.activeGreetings.has(id)) {
// Already started greeting immediately — mark as exit
const seq = this.activeGreetings.get(id)!
seq.isExit = true
seq.exitReturnPos = exitReturnPos
} else if (this.greetQueue.includes(id)) {
// In queue — when processed, processGreetQueue will set isExit=true
this.exitOnGreetComplete.add(id)
// Stash the return position until the greeting actually starts
if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos)
} else {
// No MainAgent reachable — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
}
}
/** Remove all sub-agents belonging to a parent agent */
@@ -1332,20 +1835,62 @@ export class OfficeState {
const ch = this.characters.get(id)
if (ch) {
if (ch.matrixEffect === 'despawn') {
// Already despawning — just clean up maps
this.subagentMeta.delete(id)
toRemove.push(key)
continue
}
if (this.activeGreetings.has(id)) {
const seq = this.activeGreetings.get(id)!
seq.isExit = true
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
ch.seatId = null
}
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
toRemove.push(key)
continue
}
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
ch.seatId = null
}
// Start despawn animation
ch.bubbleType = null
const parentCh = this.characters.get(parentAgentId)
const greetTile = parentCh ? this.findAdjacentWalkable(parentCh) : null
if (greetTile && parentCh) {
const path = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
if (path.length > 0) {
ch.path = path
ch.state = CharacterState.WALK
ch.moveProgress = 0
this.activeGreetings.set(id, {
childId: id,
parentId: parentAgentId,
childTarget: greetTile,
parentTarget: null,
waitTarget: null,
phase: 'walk',
timer: 0,
isExit: true,
exitReturnPos: ch.pendingDespawn && ch.pendingDespawn !== true ? ch.pendingDespawn : null,
})
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
toRemove.push(key)
continue
}
}
// No greeting path — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
ch.bubbleType = null
}
}
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
@@ -1400,6 +1945,10 @@ export class OfficeState {
ch.path = []
ch.moveProgress = 0
}
// Greet MainAgent on start-work and stop-work transitions
if (!ch.isSubagent && !ch.isSystemRole) {
this.tryStartGreeting(ch)
}
this.rebuildFurnitureInstances()
}
}
@@ -1471,6 +2020,13 @@ export class OfficeState {
}
}
setAgentTaskText(id: number, text: string | undefined): void {
const ch = this.characters.get(id)
if (ch) {
ch.taskText = text
}
}
showPermissionBubble(id: number): void {
const ch = this.characters.get(id)
if (ch) {
@@ -1525,9 +2081,170 @@ export class OfficeState {
}
}
/**
* Collision avoidance: when two walking characters contest the same next tile,
* the one farther from its destination steps aside (sideways first, then backwards)
* and waits for the closer one to pass before resuming.
*/
/** Find a free adjacent tile for a character to dodge to, or null if none found */
private findDodgeTile(
ch: Character,
dc: number, dr: number,
walkableSet: Set<string>,
occupiedKeys: Set<string>,
claimedNext: Map<string, number>,
): { col: number; row: number } | null {
const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0
? [
{ col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular)
{ col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular)
{ col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards
]
: [
// No direction info — try all 4 neighbours
{ col: ch.tileCol + 1, row: ch.tileRow },
{ col: ch.tileCol - 1, row: ch.tileRow },
{ col: ch.tileCol, row: ch.tileRow + 1 },
{ col: ch.tileCol, row: ch.tileRow - 1 },
]
for (const cand of candidates) {
const key = `${cand.col},${cand.row}`
if (!walkableSet.has(key)) continue
if (this.blockedTiles.has(key)) continue
if (occupiedKeys.has(key)) continue
if (claimedNext.has(key)) continue
return cand
}
return null
}
private resolveWalkConflicts(): void {
const allHumanoids: Character[] = []
for (const ch of this.characters.values()) {
if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue
if (ch.yieldTimer > 0) continue
allHumanoids.push(ch)
}
if (allHumanoids.length < 2) return
// Current tile positions of every character (for dodge-target exclusion)
const occupiedKeys = new Set<string>()
for (const ch of this.characters.values()) {
occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`)
}
const walkableSet = new Set<string>(this.walkableTiles.map(t => `${t.col},${t.row}`))
// claimedNext: tiles that are "taken" — no other character may step into them.
// Pre-populate with standing characters' current tiles only.
// Walking characters claim their next tile dynamically in Pass 1 (sorted by priority).
const claimedNext = new Map<string, number>()
for (const ch of allHumanoids) {
if (ch.state !== CharacterState.WALK) {
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
}
}
// ── Pass 1: next-tile conflicts ───────────────────────────────────────────
// All walkers (including mid-step), sorted by remaining path length.
// Shorter path = closer to destination = higher priority = claims the tile.
// Loser is snapped back to current tile and must dodge.
const walkers = allHumanoids.filter(
ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0,
)
walkers.sort((a, b) => a.path.length - b.path.length)
for (const ch of walkers) {
const next = ch.path[0]
const key = `${next.col},${next.row}`
if (!claimedNext.has(key)) {
// Also claim current tile so no one walks into us from behind
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
claimedNext.set(key, ch.id)
continue
}
// Tile is taken — snap back to current tile and dodge
if (ch.moveProgress > 0) {
// Abort mid-step: snap back to the tile we came from
ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2
ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2
ch.moveProgress = 0
}
const dest = ch.path[ch.path.length - 1]
const dc = next.col - ch.tileCol
const dr = next.row - ch.tileRow
const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext)
if (dodgeTile) {
ch.path = [dodgeTile]
ch.moveProgress = 0
// Face the dodge direction immediately so the character doesn't appear to
// move backward while still facing forward.
ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row)
ch.yieldDestination = { col: dest.col, row: dest.row }
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id)
} else {
ch.path = []
ch.yieldTimer = 0.5 + Math.random() * 0.4
ch.yieldDestination = { col: dest.col, row: dest.row }
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
}
}
// ── Pass 2: hard overlap — same tileCol/tileRow right now ────────────────
const tileGroups = new Map<string, Character[]>()
for (const ch of allHumanoids) {
const key = `${ch.tileCol},${ch.tileRow}`
const g = tileGroups.get(key)
if (g) g.push(ch)
else tileGroups.set(key, [ch])
}
for (const group of tileGroups.values()) {
if (group.length < 2) continue
// Highest priority (index 0) stays; others dodge
group.sort((a, b) => {
const aScore = a.state === CharacterState.WALK ? a.path.length : 9999
const bScore = b.state === CharacterState.WALK ? b.path.length : 9999
return aScore - bScore
})
for (let i = 1; i < group.length; i++) {
const dodger = group[i]
if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue
const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0
const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0
const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext)
if (dodgeTile) {
if (dodger.state === CharacterState.WALK && dodger.path.length > 0) {
const orig = dodger.path[dodger.path.length - 1]
dodger.yieldDestination = { col: orig.col, row: orig.row }
}
dodger.path = [dodgeTile]
dodger.moveProgress = 0
dodger.state = CharacterState.WALK
dodger.frame = 0
dodger.frameTimer = 0
// Face the dodge direction immediately
dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id)
occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`)
occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`)
} else {
dodger.yieldTimer = 0.3 + Math.random() * 0.3
}
}
}
}
update(dt: number): void {
this.ensureGatewaySre()
this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight)
this.resolveWalkConflicts()
const toDelete: number[] = []
const firstIdleHumanoid = this.getFirstIdleHumanoid()
for (const ch of this.characters.values()) {
@@ -1553,15 +2270,42 @@ export class OfficeState {
continue
}
if (ch.systemRoleType === 'gateway_sre') {
// Yield timer: character is waiting at dodge tile before resuming
if (ch.yieldTimer > 0) {
ch.yieldTimer = Math.max(0, ch.yieldTimer - dt)
if (ch.yieldTimer === 0 && ch.yieldDestination) {
const dest = ch.yieldDestination
ch.yieldDestination = null
const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles)
if (resumePath.length > 0) {
ch.path = resumePath
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
}
}
continue // frozen while waiting
}
if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) {
this.updateGatewaySreCharacter(ch, dt)
} else {
// Temporarily unblock own seat so character can pathfind to it
// (greetLocked guards inside updateCharacter prevent repath-to-seat during greeting)
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
}
// If character just finished walking to dodge tile, start wait timer
if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) {
ch.yieldTimer = 0.6 + Math.random() * 0.5
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
}
if (ch.isLobster) {
if (ch.lobsterRageTimer > 0) {
ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt)
@@ -1641,6 +2385,32 @@ export class OfficeState {
}
}
}
// Tick linger timers — only count down while character is actually sitting at seat
if (this.lingerSubagents.size > 0) {
const lingerExpired: number[] = []
for (const [lingerId, timer] of this.lingerSubagents) {
const ch = this.characters.get(lingerId)
if (!ch) { lingerExpired.push(lingerId); continue }
// Only count down while character is idle/typing at their seat (not walking or greeting)
const isRestingAtSeat =
ch.seatId !== null &&
ch.state !== CharacterState.WALK &&
!this.activeGreetings.has(lingerId) &&
!this.greetQueue.includes(lingerId)
if (!isRestingAtSeat) continue
const remaining = timer - dt
if (remaining <= 0) {
lingerExpired.push(lingerId)
} else {
this.lingerSubagents.set(lingerId, remaining)
}
}
for (const lingerId of lingerExpired) {
this.lingerSubagents.delete(lingerId)
this.startSubagentFarewell(lingerId)
}
}
this.updateGreetings(dt)
// Remove characters that finished despawn
for (const id of toDelete) {
this.characters.delete(id)
+90
View File
@@ -218,6 +218,22 @@ interface ZDrawable {
draw: (ctx: CanvasRenderingContext2D) => void
}
/** Wrap task text at maxChars characters or at Chinese punctuation boundaries */
function wrapTaskText(text: string, maxChars = 10): string[] {
const punctuation = /[,。!?、;:,!?;:]/
const lines: string[] = []
let current = ''
for (let i = 0; i < text.length; i++) {
current += text[i]
if (punctuation.test(text[i]) || current.length >= maxChars) {
lines.push(current)
current = ''
}
}
if (current) lines.push(current)
return lines.slice(0, 6) // max 6 lines
}
export function renderScene(
ctx: CanvasRenderingContext2D,
furniture: FurnitureInstance[],
@@ -578,6 +594,80 @@ export function renderScene(
})
}
// Task text bubble: scrolling marquee above (or below if near top) the agent's head
if (ch.taskText && ch.isActive && ch.state === CharacterState.TYPE && !ch.isSubagent) {
const taskX = Math.round(offsetX + ch.x * zoom)
const labelFontSize = Math.max(12, Math.round(5.25 * zoom))
const taskFontSize = Math.max(10, Math.round(4.5 * zoom))
const padX = 5 * zoom
const padY = 3 * zoom
const bubbleH = taskFontSize + padY * 2
const bubbleW = Math.round(72 * zoom)
const boxX = Math.max(2, Math.min(taskX - bubbleW / 2, (ctx.canvas.width - bubbleW - 2)))
const idealBoxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom
// If bubble would be clipped by top edge, flip it below the character instead
const belowY = drawY + cached.height + labelFontSize + 4 * zoom
const tailUp = idealBoxY >= 4 // tail points down when bubble is above, up when below
const boxY = idealBoxY >= 4 ? idealBoxY : belowY
const fullText = ch.taskText
drawables.push({
zY: charZY + 0.2,
draw: (c) => {
c.save()
c.font = `${taskFontSize}px sans-serif`
const fullW = c.measureText(fullText).width
const gap = bubbleW * 0.5
const cycle = fullW + gap
const speed = 30
const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle)
const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0)
const r = 3 * zoom
const tailW = 5 * zoom
c.beginPath()
c.moveTo(boxX + r, boxY)
c.lineTo(boxX + bubbleW - r, boxY)
c.arcTo(boxX + bubbleW, boxY, boxX + bubbleW, boxY + r, r)
c.lineTo(boxX + bubbleW, boxY + bubbleH - r)
c.arcTo(boxX + bubbleW, boxY + bubbleH, boxX + bubbleW - r, boxY + bubbleH, r)
if (tailUp) {
// Tail at bottom pointing down toward label
c.lineTo(taskX + tailW, boxY + bubbleH)
c.lineTo(taskX, boxY + bubbleH + 4 * zoom)
c.lineTo(taskX - tailW, boxY + bubbleH)
}
c.lineTo(boxX + r, boxY + bubbleH)
c.arcTo(boxX, boxY + bubbleH, boxX, boxY + bubbleH - r, r)
if (!tailUp) {
// Tail at top pointing up toward character
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
c.lineTo(taskX - tailW, boxY)
c.lineTo(taskX, boxY - 4 * zoom)
c.lineTo(taskX + tailW, boxY)
} else {
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
}
c.closePath()
c.fillStyle = 'rgba(15,23,42,0.88)'
c.fill()
c.strokeStyle = 'rgba(99,102,241,0.7)'
c.lineWidth = zoom
c.stroke()
c.beginPath()
c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH)
c.clip()
c.fillStyle = '#e2e8f0'
c.textAlign = 'left'
c.textBaseline = 'middle'
c.fillText(fullText, textX, boxY + bubbleH / 2)
c.restore()
},
})
}
// Code snippet particles are rendered as DOM overlays in app/pixel-office/page.tsx
// so they can float beyond the canvas area and pass over the top agent list.
}
+10 -8
View File
@@ -236,16 +236,17 @@ const RIGHT_WALL_STOOLS: ReadonlyArray<PlacedFurniture> = [
{ uid: 'stool-r7', type: FurnitureType.BENCH, col: 17, row: 6 },
{ uid: 'stool-r8', type: FurnitureType.BENCH, col: 17, row: 7.5 },
]
const LEFT_WALL_SERVER: Readonly<PlacedFurniture> = {
uid: 'server-b-left',
const RIGHT_WALL_SERVER: Readonly<PlacedFurniture> = {
uid: 'server-b-right',
type: FurnitureType.SERVER_RACK,
col: 1,
col: 18,
row: 12,
}
function shouldRemoveRightOfficeLegacyItems(item: PlacedFurniture): boolean {
if (item.uid.startsWith('stool-r')) return true
if (item.uid === 'plant-r1' || item.uid === 'lamp-r' || item.uid === 'cooler-r') return true
if (item.uid === 'server-b-left') return true // migrated to right wall
if (item.type === FurnitureType.PLANT && item.col === 19 && item.row === 3) return true
if (item.type === FurnitureType.LAMP && item.col === 19 && item.row === 7) return true
if (item.type === FurnitureType.COOLER && item.col === 18 && item.row === 7) return true
@@ -259,8 +260,8 @@ function normalizeRightOfficeFurniture(furniture: PlacedFurniture[]): PlacedFurn
const exists = next.some((item) => item.uid === stool.uid)
if (!exists) next.push({ ...stool })
}
if (!next.some((item) => item.uid === LEFT_WALL_SERVER.uid)) {
next.push({ ...LEFT_WALL_SERVER })
if (!next.some((item) => item.uid === RIGHT_WALL_SERVER.uid)) {
next.push({ ...RIGHT_WALL_SERVER })
}
return next
}
@@ -358,7 +359,6 @@ export function createDefaultLayout(): OfficeLayout {
{ uid: 'camera-r', type: FurnitureType.CAMERA, col: 13.5, row: 3.5 },
{ uid: 'whiteboard-r', type: FurnitureType.WHITEBOARD, col: 15, row: 0 },
{ uid: 'library-r', type: FurnitureType.LIBRARY_GRAY_FULL, col: 17.5, row: -0.5 },
{ uid: 'clock-r', type: FurnitureType.CLOCK, col: 11, row: 0 },
...RIGHT_WALL_STOOLS,
// ── Right room meeting corner ──
@@ -366,13 +366,15 @@ export function createDefaultLayout(): OfficeLayout {
// ── Bottom lounge / break area ──
{ uid: 'fridge-b', type: FurnitureType.FRIDGE, col: 1, row: 9.5 },
{ ...LEFT_WALL_SERVER },
{ ...RIGHT_WALL_SERVER },
{ uid: 'water-cooler-b', type: FurnitureType.WATER_COOLER, col: 8, row: 9.5 },
{ uid: 'deco-b', type: FurnitureType.DECO_3, col: 9, row: 9.5 },
{ uid: 'plant-b1', type: FurnitureType.PLANT, col: 1, row: 15 },
{ uid: 'plant-b2', type: FurnitureType.PLANT_SMALL, col: 19, row: 15 },
{ uid: 'plant-b3', type: FurnitureType.PLANT_SMALL, col: 19, row: 10.5 },
{ uid: 'painting-l2', type: FurnitureType.PAINTING_LARGE_2, col: 11, row: 10 },
{ uid: 'painting-corridor-l', type: FurnitureType.PAINTING_SMALL_1, col: 3, row: 9 },
{ uid: 'clock-corridor', type: FurnitureType.CLOCK, col: 11, row: 9 },
{ uid: 'painting-corridor-r', type: FurnitureType.PAINTING_LARGE_2, col: 12, row: 9 },
{ uid: 'bookshelf-b', type: FurnitureType.BOOKSHELF, col: 18, row: 9.5 },
{ uid: 'sofa-b', type: FurnitureType.SOFA, col: 10, row: 14, rotation: 180 },
{ uid: 'bench-b1', type: FurnitureType.BENCH, col: 8, row: 15 },
+9
View File
@@ -196,6 +196,7 @@ export interface Character {
seatTimer: number
isSubagent: boolean
parentAgentId: number | null
greetLocked: boolean
label: string
matrixEffect: 'spawn' | 'despawn' | null
matrixEffectTimer: number
@@ -211,4 +212,12 @@ export interface Character {
isSystemRole?: boolean
systemRoleType?: 'gateway_sre'
systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down'
/** Last assigned task text to display above the agent's head */
taskText?: string
/** Walk back to this tile after farewell greeting, then despawn */
pendingDespawn?: { col: number; row: number } | true
/** Seconds remaining while character waits at dodge tile before resuming path */
yieldTimer: number
/** Original destination to repath to after yield completes */
yieldDestination: { col: number; row: number } | null
}
+1 -1
View File
@@ -113,7 +113,7 @@ export function getWallInstances(
sprite: wallInfo.sprite,
x: c * TILE_SIZE,
y: r * TILE_SIZE + wallInfo.offsetY,
zY: (r + 1) * TILE_SIZE,
zY: r * TILE_SIZE,
})
}
}
+12 -1
View File
@@ -1,5 +1,16 @@
import { execSync } from "child_process";
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
env: {
NEXT_PUBLIC_DASHBOARD_VERSION: (() => {
try {
return execSync("git log -1 --format=%cd --date=format:%y.%m%d", { encoding: "utf8" }).trim();
} catch {
return "";
}
})(),
},
};
export default nextConfig;