diff --git a/.gitignore b/.gitignore index 1e9aa45..dba28c5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index b1fa779..70ab55b 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -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, +): 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 @@ -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() + const activeSubtasks = new Map() const spawnToolIds = new Set() + /** toolIds whose spawn was accepted and are awaiting a text response from parent */ + const pendingResponseIds = new Set() 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 { + 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)) { - agentSessionsDir = path.join(agentsDir, agent.id, 'sessions') - if (existsSync(agentSessionsDir)) { - try { - const files = await fs.readdir(agentSessionsDir) - for (const file of files) { - const filePath = path.join(agentSessionsDir, file) - const stat = await fs.stat(filePath) - if (stat.mtimeMs > lastActive) { - lastActive = stat.mtimeMs + // 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 + } 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() + 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 + 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 */ } + + // 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, }) } } diff --git a/app/api/config-backup/route.ts b/app/api/config-backup/route.ts new file mode 100644 index 0000000..482f884 --- /dev/null +++ b/app/api/config-backup/route.ts @@ -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 } + ); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 003dd80..8d7a34b 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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); diff --git a/app/api/gateway-health/route.ts b/app/api/gateway-health/route.ts index 1adfd53..55c19e8 100644 --- a/app/api/gateway-health/route.ts +++ b/app/api/gateway-health/route.ts @@ -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, }); diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts new file mode 100644 index 0000000..5079a26 --- /dev/null +++ b/app/api/gateway-logs/route.ts @@ -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(); + 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 }); + } +} diff --git a/app/api/gateway-restart/route.ts b/app/api/gateway-restart/route.ts new file mode 100644 index 0000000..bf2dfd4 --- /dev/null +++ b/app/api/gateway-restart/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/app/api/test-bound-models/route.ts b/app/api/test-bound-models/route.ts index ebb9a2f..8842d3c 100644 --- a/app/api/test-bound-models/route.ts +++ b/app/api/test-bound-models/route.ts @@ -41,7 +41,8 @@ export async function POST() { const modelProbeTasks = new Map>>>(); 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); diff --git a/app/gateway-status.tsx b/app/gateway-status.tsx index dc24149..4fe42b3 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -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(null); - const [showError, setShowError] = useState(false); + const [logResult, setLogResult] = useState(null); + const [showDetail, setShowDetail] = useState(false); const [showVersionTip, setShowVersionTip] = useState(false); + const [restarting, setRestarting] = useState(false); + const [restartMsg, setRestartMsg] = useState(null); - const check = useCallback(() => { + // Config backup/restore state + const [backups, setBackups] = useState([]); + const [restoring, setRestoring] = useState(false); + const [restoreMsg, setRestoreMsg] = useState(null); + const [reloadCountdown, setReloadCountdown] = useState(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(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 (
+ {/* Gateway link badge */} + {showVersionTip && (
{gatewayTitle}
)} + + {/* Health indicator */} {!health ? ( -- - ) : health.ok ? ( + ) : health.ok && !showWarning ? ( + ) : showWarning ? ( + ⚠️ ) : ( setShowError((v) => !v)} + onClick={handleDetailClick} >❌ )} - {showError && health && !health.ok && health.error && ( -
- {health.error} + + {/* Restart button — shown when there's a problem */} + {showRestart && ( + + )} + + {/* Detail panel */} + {showDetail && ( +
+
+ Gateway 狀態 + +
+ +
+ {/* Health status */} +
+ Process: + + {health?.ok ? "✅ 運作中" : "❌ 無回應"} + +
+ + {/* Telegram stall */} + {logResult && logResult.issues.includes("telegram_stall") && ( +
+ Telegram: + + ⚠️ Polling 異常 + {logResult.lastStallAt && ( + + ({new Date(logResult.lastStallAt).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" })}) + + )} + +
+ )} + + {/* Subagent timeout */} + {logResult && logResult.issues.includes("subagent_timeout") && ( +
+ Subagent: + ⚠️ 有 timeout 記錄 +
+ )} + + {/* Error message when down */} + {health && !health.ok && health.error && ( +
+ {health.error} +
+ )} + + {/* Log toggle — always visible when gateway is down */} + {health && !health.ok && ( + + )} + + {/* Log viewer */} + {showLogs && ( +
+ {logResult?.recentLines && logResult.recentLines.length > 0 ? ( +
+                    {logResult.recentLines.join("\n")}
+                  
+ ) : ( +

+ )} +
+ )} + + {/* Config change prompt — prominent banner when config recently changed */} + {showConfigChangePrompt && ( +
+
+ ⚠️ +
+
{t("gateway.noResponse")}
+
{t("gateway.configChanged")}
+
+
+ + {/* Action buttons */} +
+ {(() => { + const recommended = findRecommendedBackup(backups); + return recommended ? ( + + ) : null; + })()} + +
+
+ )} + + {/* Config error hint + backup restore (when no recent change detected, or dismissed the prompt) */} + {showConfigHint && !showConfigChangePrompt && ( +
+
+ 📋 +
+
{t("gateway.configError")}
+
{t("gateway.configErrorDesc")}
+
+
+ + {/* Backup list */} +
+
+ {t("gateway.backupAvailable")} ({backups.length}) +
+ {backups.map((b) => { + const isRecommended = b.sizeBytes >= 1024; + const isSuspect = b.sizeBytes < 1024; + return ( +
+
+ + {formatBackupTime(b.timestamp)} + + + {formatSize(b.sizeBytes)} + + {isRecommended && ( + {t("gateway.backupRecommended")} + )} + {isSuspect && ( + {t("gateway.backupSuspect")} + )} +
+ +
+ ); + })} +
+
+ )} + + {/* When gateway is down but no backups available */} + {!health?.ok && consecutiveDownCount >= 3 && backups.length === 0 && ( +
+ 📋 {t("gateway.noBackups")} +
+ )} + + {/* Restore result message */} + {restoreMsg && ( +
+ {restoreMsg} +
+ )} + + {/* Countdown to reload */} + {reloadCountdown !== null && ( +
+
+ + 🔄 {reloadCountdown} {t("gateway.reloadCountdown")} + + +
+
+ {t("gateway.reloadHint")} +
+
+ )} + + {/* Restart result message */} + {restartMsg && ( +
+ {restartMsg} +
+ )} +
+ + {/* Restart button */} +
+ +
)}
); } + +/** 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; +} diff --git a/app/globals.css b/app/globals.css index 71b55dd..1d792f9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/page.tsx b/app/page.tsx index 9025d88..15bb9d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -556,8 +556,16 @@ export default function Home() { if (error && !data) { return ( -
-

{t("common.loadError")}: {error}

+
+
+ +
+
+

{t("common.loadError")}: {error}

+

+ {t("gateway.configCorruptHint")} +

+
); } diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 7db4610..cbc61d2 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -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 = { 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() {
-
- {displayAgents.length === 0 ? ( -
{t('common.noData')}
- ) : ( -
- {mobileAgentPages.map((page, pageIndex) => ( -
- {page.map((agent) => renderAgentChip(agent, true))} - {page.length < 9 && Array.from({ length: 9 - page.length }).map((_, i) => ( -
- ))} -
- ))} -
- )} -
+{/* Mobile agent list moved to canvas overlay below */}
{displayAgents.map((agent) => renderAgentChip(agent))} {displayAgents.length === 0 && ( @@ -1825,6 +1843,15 @@ export default function PixelOfficePage() {
)} + {/* Mobile agent list overlay at bottom of canvas */} + {isMobileViewport && ( +
+
+ {displayAgents.map((agent) => renderAgentChip(agent, true))} +
+
+ )} + {/* Broadcast notifications */} {broadcasts.length > 0 && (
diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 3b47ea6..a953b09 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -515,14 +515,15 @@ export function Sidebar() { 🦞
-
- OPENCLAW{mobileOpenclawVersion ? ` ${mobileOpenclawVersion}` : ""} -
+
OPENCLAW
{pathname === "/" && mobileAgentCount !== null ? `${mobileAgentCount} ${t("home.agentCount")}` : mobileCurrent ? t(mobileCurrent.labelKey) : "BOT DASHBOARD"}
+ {mobileOpenclawVersion && ( +
v{mobileOpenclawVersion}
+ )}
@@ -695,6 +696,9 @@ export function Sidebar() {
OPENCLAW
BOT DASHBOARD
+ {process.env.NEXT_PUBLIC_DASHBOARD_VERSION && ( +
v{process.env.NEXT_PUBLIC_DASHBOARD_VERSION}
+ )}