From 55bfed41ef3b9c4b605231ad344b648de6ed4208 Mon Sep 17 00:00:00 2001 From: danlee Date: Thu, 12 Mar 2026 08:32:02 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0ollama=20api=20?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/gateway-health/route.ts | 6 +++++- lib/model-probe.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/api/gateway-health/route.ts b/app/api/gateway-health/route.ts index 1adfd53..2698af8 100644 --- a/app/api/gateway-health/route.ts +++ b/app/api/gateway-health/route.ts @@ -17,7 +17,11 @@ function quoteShellArg(arg: string): string { } 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: `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`, + }; if (process.platform !== "win32") { return execFileAsync("openclaw", args, { diff --git a/lib/model-probe.ts b/lib/model-probe.ts index 9a6978d..e8454c8 100644 --- a/lib/model-probe.ts +++ b/lib/model-probe.ts @@ -65,7 +65,11 @@ function quoteShellArg(arg: string): string { } 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: `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`, + }; if (process.platform !== "win32") { return execFileAsync("openclaw", args, { @@ -248,6 +252,32 @@ async function probeModelDirect(params: ProbeModelParams): Promise Date: Thu, 12 Mar 2026 08:54:13 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0openai=20=E7=9B=B8?= =?UTF-8?q?=E5=AE=B9=20api=20=E6=B8=AC=E8=A9=A6support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sidebar.tsx | 3 +++ lib/model-probe.ts | 3 ++- next.config.mjs | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 367056b..f65e162 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -654,6 +654,9 @@ export function Sidebar() {
OPENCLAW
BOT DASHBOARD
+ {process.env.NEXT_PUBLIC_DASHBOARD_VERSION && ( +
v{process.env.NEXT_PUBLIC_DASHBOARD_VERSION}
+ )}
+ )} + + {/* 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} +
+ )} + + {/* Restart result message */} + {restartMsg && ( +
+ {restartMsg} +
+ )} +
+ + {/* Restart button */} +
+ +
)} diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 5ee66f3..39c0ffd 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1180,12 +1180,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 +1654,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]) From 575fcd6d63057504c107acd62905b39c9b4be2c9 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sat, 14 Mar 2026 07:26:52 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E8=A1=8C=E5=8B=95?= =?UTF-8?q?=E8=B7=AF=E7=B7=9A=E7=AD=96=E7=95=A5=EF=BC=8C=E6=B8=9B=E5=B0=91?= =?UTF-8?q?=E7=A2=B0=E6=92=9E=E6=A9=9F=E6=9C=83=EF=BC=88=E9=82=84=E6=98=AF?= =?UTF-8?q?=E6=9C=83=E7=A2=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pixel-office/page.tsx | 34 +++++ lib/pixel-office/engine/characters.ts | 6 +- lib/pixel-office/engine/officeState.ts | 182 +++++++++++++++++++++++++ lib/pixel-office/types.ts | 4 + 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 39c0ffd..e7df71c 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -892,6 +892,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 { diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 2d284a2..c8ea8d7 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -103,6 +103,8 @@ export function createCharacter( codeSnippets: [], photoComments: [], isViewingPhoto: false, + yieldTimer: 0, + yieldDestination: null, } } @@ -366,8 +368,8 @@ export function updateCharacter( ch.moveProgress = 0 } - // If became active while wandering, repath to seat (skip if under greeting control) - if (ch.isActive && ch.seatId && !ch.greetLocked) { + // 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] diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 6e646be..70699f4 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1913,9 +1913,165 @@ 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, + occupiedKeys: Set, + claimedNext: Map, + ): { 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() + for (const ch of this.characters.values()) { + occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`) + } + + const walkableSet = new Set(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() + 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 + 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() + 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 + 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()) { @@ -1941,6 +2097,24 @@ export class OfficeState { continue } + // 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 { @@ -1951,6 +2125,14 @@ export class OfficeState { ) } + // 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) diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index bd0e8b9..e59582b 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -214,4 +214,8 @@ export interface Character { systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' /** 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 } From 0d1501f7ad94459106567284c757feb24b152bde Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 00:04:02 +0800 Subject: [PATCH 07/18] feat: improve agent state detection and pixel office greeting logic - Detect agent state by reading session JSONL files instead of fixed time thresholds - Add detectStateFromSession() to determine idle/working/offline states accurately - Fix pixel office: only show 'online' broadcast when agent returns from offline (not idle) - Add meeting point logic: subagent meets MainAgent at midpoint when MainAgent is not at seat --- app/api/agent-activity/route.ts | 103 +++++++++++++++++++++++-- app/pixel-office/page.tsx | 3 +- lib/pixel-office/engine/officeState.ts | 102 ++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 15 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index bd5bb86..a7fc4d5 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -560,6 +560,58 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis return allSubagents } +/** + * 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'> { + const OFFLINE_MS = 10 * 60 * 1000 + const timeDiff = now - lastActive + if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + try { + const content = await fs.readFile(sessionFilePath, 'utf8') + const lines = content.split('\n').filter(l => l.trim()).slice(-30) + + 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 */ } + } + + // User message is the last written record → agent is generating a response right now + if (lastRole === 'user') return 'working' + // toolResult as the latest message → agent is processing the tool output + if (lastRole === 'toolResult') return 'working' + // Agent called a tool and is waiting for the result + if (lastStopReason === 'toolUse') return 'working' + // Agent completed its turn — it's now idle + if (lastStopReason === 'stop') return 'idle' + } catch { /* file unreadable — fall through */ } + + // Fallback: time-based (no session content available) + return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' +} + export async function GET() { const configPath = OPENCLAW_CONFIG_PATH const agentsDir = OPENCLAW_AGENTS_DIR @@ -577,34 +629,69 @@ export async function GET() { for (const agent of agentList) { let lastActive = 0 + let mostRecentSessionFile: string | null = null let agentSessionsDir = '' 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 */ } + + // Prefer the sessions.json-mapped file over raw scan when available + // (ensures we read a proper session, not a probe or temp file) + if (mostRecentSessionFile) { + const sessionId = path.basename(mostRecentSessionFile, '.jsonl') + if (!sessionIdToFile.has(sessionId)) { + // Most-recent file is not in sessions.json — find best known session by mtime + 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 */ } } } - } catch { - // Ignore } } } + // 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 diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index e7df71c..c2c4e39 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) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 70699f4..4657202 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -390,6 +390,8 @@ 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' @@ -439,6 +441,8 @@ export class OfficeState { private lingerSubagents: Map = new Map() /** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */ private exitOnGreetComplete: Set = new Set() + /** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */ + private explicitlyIdledAgents: Set = new Set() /** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */ private exitReturnStash: Map = new Map() @@ -893,6 +897,41 @@ export class OfficeState { 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 @@ -935,7 +974,25 @@ export class OfficeState { this.greetQueue.length = 0 return } - const greetTile = this.findAdjacentWalkable(mainCh) + // 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 } @@ -944,6 +1001,20 @@ export class OfficeState { 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 @@ -953,6 +1024,7 @@ export class OfficeState { childId: ch.id, parentId: this.mainAgentId!, childTarget: greetTile, + parentTarget, waitTarget: null, phase: 'walk', timer: 0, @@ -989,9 +1061,27 @@ export class OfficeState { const target = seq.childTarget if (!target) { seq.phase = 'complete'; break } - // Detected arrival: child tile matches target - if (child.tileCol === target.col && child.tileRow === target.row) { - // Override any FSM re-path + // 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 @@ -1005,14 +1095,13 @@ export class OfficeState { seq.phase = 'pause' seq.timer = 1.0 } else if (child.path.length === 0 && child.state !== CharacterState.WALK) { - // Lost path mid-walk — try to re-path to target + // 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 { - // Can't reach — complete without greeting seq.phase = 'complete' } } @@ -1739,6 +1828,7 @@ export class OfficeState { childId: id, parentId: parentAgentId, childTarget: greetTile, + parentTarget: null, waitTarget: null, phase: 'walk', timer: 0, From c826424add6c916a47d731aef1ebebc23068d704 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 10:15:35 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20openclaw.json=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E5=82=99=E4=BB=BD=E3=80=81=E5=81=B5=E6=B8=AC=E6=90=8D?= =?UTF-8?q?=E6=AF=80=E8=88=87=E9=82=84=E5=8E=9F=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 備份機制 - 新增 lib/config-backup.ts:以 SHA-256 hash 偵測 openclaw.json 變更,自動備份 - Hash 持久化至 ~/.openclaw/backups/config/.last-hash,Next.js 重啟後仍能正確偵測變更 - 備份保留策略:滾動 8 個 + 昨天錨點 + 上週錨點(各取最後一個正常備份,≥ 1 KB) - 新增 API:GET /api/config-backup(列出備份)、POST /api/config-backup(還原) ## Dashboard 偵測與還原 UI(gateway-status.tsx) - Gateway 第一次失敗即觸發備份清單抓取,不等 30 秒 - 備份清單顯示大小、[建議] / [可能損毀] 標籤,並自動推薦第一個正常備份 - 還原後自動重啟 Gateway,並倒數 5 秒後自動重新整理頁面 - 提示使用者點選機器人卡片「測試」確認是否正常 - 「查看錯誤日誌」按鈕固定顯示於面板,可展開/收起最後 30 行 log - config 損毀導致頁面載入失敗時,錯誤頁仍顯示 GatewayStatus 還原入口 ## 多語系支援 - 所有新增文字支援繁中/简中/English(i18n.tsx 新增 8 個 key) Co-Authored-By: Claude Sonnet 4.6 --- app/api/config-backup/route.ts | 61 +++++++ app/api/config/route.ts | 13 ++ app/api/gateway-logs/route.ts | 4 + app/gateway-status.tsx | 303 +++++++++++++++++++++++++++++++-- app/page.tsx | 12 +- lib/config-backup.ts | 235 +++++++++++++++++++++++++ lib/i18n.tsx | 57 +++++++ 7 files changed, 669 insertions(+), 16 deletions(-) create mode 100644 app/api/config-backup/route.ts create mode 100644 lib/config-backup.ts 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 c66f279..faf3a57 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { detectChangeAndBackup } from "@/lib/config-backup"; // 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw const CONFIG_PATH = OPENCLAW_CONFIG_PATH; @@ -262,6 +263,10 @@ export async function GET() { try { const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + + // 偵測 openclaw.json 是否有變更,若有則自動備份 + detectChangeAndBackup(raw); + const config = JSON.parse(raw); // 提取 agents 信息 @@ -536,6 +541,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, @@ -546,6 +558,7 @@ export async function GET() { host: process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL || config.gateway?.host || config.gateway?.hostname || "", }, groupChats, + configLastModified, }; configCache = { data, ts: Date.now() }; return NextResponse.json(data); diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts index 4a27565..5079a26 100644 --- a/app/api/gateway-logs/route.ts +++ b/app/api/gateway-logs/route.ts @@ -39,11 +39,15 @@ export async function GET() { ? 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/gateway-status.tsx b/app/gateway-status.tsx index e0ea97a..4fe42b3 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -25,6 +25,13 @@ interface LogResult { issues: string[]; lastStallAt: string | null; stallActive: boolean; + recentLines?: string[]; +} + +interface BackupEntry { + filename: string; + timestamp: string; + sizeBytes: number; } interface GatewayStatusProps { @@ -42,16 +49,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil const [restarting, setRestarting] = useState(false); const [restartMsg, setRestartMsg] = useState(null); - const checkHealth = useCallback(() => { - fetch("/api/gateway-health") - .then((r) => r.json()) - .then((d: HealthResult) => { - setHealth(d); - // If health is down, also fetch logs for more context - if (!d.ok) fetchLogs(); - }) - .catch(() => setHealth({ ok: false, error: t("gateway.fetchError") })); - }, [t]); + // 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") @@ -60,6 +68,46 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil .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: 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(() => { checkHealth(); const timer = setInterval(checkHealth, 10000); @@ -67,10 +115,15 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil }, [checkHealth]); const handleDetailClick = useCallback(() => { - setShowDetail((v) => !v); - // Fetch fresh logs whenever the user opens the detail panel - fetchLogs(); - }, [fetchLogs]); + setShowDetail((v) => { + if (!v) { + // Opening panel — fetch fresh data + fetchLogs(); + fetchBackups(); + } + return !v; + }); + }, [fetchLogs, fetchBackups]); const handleRestart = useCallback(async () => { if (restarting) return; @@ -96,6 +149,45 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil } }, [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"; @@ -105,6 +197,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil 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 (
@@ -220,6 +323,149 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
)} + {/* 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 && (
); } + +/** 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/page.tsx b/app/page.tsx index cef3a75..3eb404c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -526,8 +526,16 @@ export default function Home() { if (error && !data) { return ( -
-

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

+
+
+ +
+
+

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

+

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

+
); } diff --git a/lib/config-backup.ts b/lib/config-backup.ts new file mode 100644 index 0000000..b9d5c61 --- /dev/null +++ b/lib/config-backup.ts @@ -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(); + + // 昨天錨點 + 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; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 210af68..4813505 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -221,6 +221,25 @@ const translations: Record> = { "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 辦公室", @@ -481,6 +500,25 @@ const translations: Record> = { "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办公室", @@ -741,6 +779,25 @@ const translations: Record> = { "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", From 2352ee9b85b9c1ad928339784d631f46c772cda1 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 11:31:53 +0800 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20=E5=83=8F=E7=B4=A0=E8=BE=A6?= =?UTF-8?q?=E5=85=AC=E5=AE=A4=E9=A1=AF=E7=A4=BA=20Agent=20=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E5=9F=B7=E8=A1=8C=E7=9A=84=E4=BA=A4=E8=BE=A6=E4=BB=BB?= =?UTF-8?q?=E5=8B=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-activity API 新增 lastTask 欄位,回傳 working 中 Agent 最後被交辦的任務內容 - 支援兩種格式:Telegram 訊息(``` fence 後的文字)與子 Agent spawn([Subagent Task]: 內容) - Character 新增 taskText 欄位,OfficeState 新增 setAgentTaskText() 方法 - agentBridge 同步任務文字至像素辦公室角色 - renderer 在 working 中的 Agent 頭上顯示固定寬度氣泡,內容過長自動跑馬燈捲動 Co-Authored-By: Claude Sonnet 4.6 --- app/api/agent-activity/route.ts | 91 ++++++++++++++++++++++++++ lib/pixel-office/agentBridge.ts | 9 ++- lib/pixel-office/engine/officeState.ts | 7 ++ lib/pixel-office/engine/renderer.ts | 78 ++++++++++++++++++++++ lib/pixel-office/types.ts | 2 + 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index a7fc4d5..a7afc78 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -38,6 +38,7 @@ export interface AgentActivity { toolStatus?: string lastActive: number subagents?: SubagentInfo[] + lastTask?: string } type AgentConfigEntry = { @@ -560,6 +561,89 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis return allSubagents } +/** + * 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. * @@ -701,6 +785,12 @@ export async function GET() { if (subagents.length === 0) subagents = 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, @@ -708,6 +798,7 @@ export async function GET() { state, lastActive, subagents, + lastTask, }) } } diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index 44c66d2..2ab37a5 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -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 */ @@ -60,10 +61,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 (agent name with id in parentheses) @@ -76,10 +77,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) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 4657202..63406cd 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1949,6 +1949,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) { diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 5c599a6..2f7dbeb 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -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[], @@ -559,6 +575,68 @@ export function renderScene( }) } + // Task text bubble: scrolling marquee above the agent's head when working + 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) // fixed width window + const boxX = taskX - bubbleW / 2 + const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + 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 + // Scroll speed: pixels per ms. Pause at start/end with a gap. + const gap = bubbleW * 0.5 + const cycle = fullW + gap + const speed = 30 // px/s at zoom=1, scaled below + const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) + const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) + + // Bubble background + const r = 3 * 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) + // Tail pointing down + const tailW = 5 * zoom + 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) + 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() + + // Clip to bubble interior and draw scrolling text + 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. } diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index e59582b..e2c5829 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -212,6 +212,8 @@ 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 */ From b12a5c99723a1538c2c78b2230820d4420df1600 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 20 Mar 2026 10:42:35 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E8=AA=BF=E6=95=B4pix=20office=20?= =?UTF-8?q?=E8=A1=8C=E7=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/agent-activity/route.ts | 55 +++++++++++++++---- app/api/config/route.ts | 22 +++++++- app/globals.css | 3 ++ app/pixel-office/page.tsx | 26 ++++----- lib/pixel-office/engine/officeState.ts | 75 +++++++++++++++++++------- lib/pixel-office/engine/renderer.ts | 42 +++++++++------ 6 files changed, 162 insertions(+), 61 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index a7afc78..5569505 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -658,9 +658,14 @@ async function detectStateFromSession( now: number, lastActive: number, ): Promise<'idle' | 'working' | 'offline'> { - const OFFLINE_MS = 10 * 60 * 1000 + 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 - if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + // Last activity > 10 min ago → offline regardless of session content + if (timeDiff > OFFLINE_MS) return 'offline' try { const content = await fs.readFile(sessionFilePath, 'utf8') @@ -682,17 +687,15 @@ async function detectStateFromSession( } catch { /* skip malformed line */ } } - // User message is the last written record → agent is generating a response right now - if (lastRole === 'user') return 'working' - // toolResult as the latest message → agent is processing the tool output - if (lastRole === 'toolResult') return 'working' - // Agent called a tool and is waiting for the result - if (lastStopReason === 'toolUse') return 'working' - // Agent completed its turn — it's now idle + // Work completed → immediately idle if (lastStopReason === 'stop') return 'idle' + // Still processing — but cap at 10 min, after that force idle + if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') { + return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle' + } } catch { /* file unreadable — fall through */ } - // Fallback: time-based (no session content available) + // Fallback within the 10-min window return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' } @@ -716,6 +719,36 @@ export async function GET() { 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)) { @@ -794,7 +827,7 @@ export async function GET() { 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, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index faf3a57..feff71e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -232,6 +232,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, @@ -354,7 +373,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); // 查找绑定的平台 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/pixel-office/page.tsx b/app/pixel-office/page.tsx index c2c4e39..d6b5d13 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1814,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 && ( @@ -1858,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/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 63406cd..6502cef 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -399,6 +399,8 @@ interface GreetingSequence { 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 { @@ -1091,6 +1093,14 @@ export class OfficeState { 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 @@ -1169,13 +1179,8 @@ export class OfficeState { if (seq.isExit) { child.bubbleType = null - const walkBack = this.findExitWalkPath(child, seq.exitReturnPos) - if (walkBack) { - child.path = walkBack.path - child.state = CharacterState.WALK - child.moveProgress = 0 - child.pendingDespawn = walkBack.target - } else { + // 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() @@ -1231,6 +1236,38 @@ export class OfficeState { 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. @@ -1768,14 +1805,8 @@ export class OfficeState { // Stash the return position until the greeting actually starts if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos) } else { - // No MainAgent reachable — walk back directly then despawn - const walkBack = this.findExitWalkPath(ch, exitReturnPos) - if (walkBack) { - ch.pendingDespawn = walkBack.target - ch.path = walkBack.path - ch.state = CharacterState.WALK - ch.moveProgress = 0 - } else { + // No MainAgent reachable — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { ch.matrixEffect = 'despawn' ch.matrixEffectTimer = 0 ch.matrixEffectSeeds = matrixEffectSeeds() @@ -1842,9 +1873,12 @@ export class OfficeState { continue } } - ch.matrixEffect = 'despawn' - ch.matrixEffectTimer = 0 - ch.matrixEffectSeeds = matrixEffectSeeds() + // No greeting path — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { + ch.matrixEffect = 'despawn' + ch.matrixEffectTimer = 0 + ch.matrixEffectSeeds = matrixEffectSeeds() + } } this.subagentMeta.delete(id) if (this.selectedAgentId === id) this.selectedAgentId = null @@ -2107,6 +2141,9 @@ export class OfficeState { 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) @@ -2155,6 +2192,8 @@ export class OfficeState { 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}`) diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 2f7dbeb..670e978 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -575,7 +575,7 @@ export function renderScene( }) } - // Task text bubble: scrolling marquee above the agent's head when working + // 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)) @@ -583,9 +583,13 @@ export function renderScene( const padX = 5 * zoom const padY = 3 * zoom const bubbleH = taskFontSize + padY * 2 - const bubbleW = Math.round(72 * zoom) // fixed width window - const boxX = taskX - bubbleW / 2 - const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + 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, @@ -593,30 +597,39 @@ export function renderScene( c.save() c.font = `${taskFontSize}px sans-serif` const fullW = c.measureText(fullText).width - // Scroll speed: pixels per ms. Pause at start/end with a gap. const gap = bubbleW * 0.5 const cycle = fullW + gap - const speed = 30 // px/s at zoom=1, scaled below + const speed = 30 const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) - // Bubble background 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) - // Tail pointing down - const tailW = 5 * zoom - c.lineTo(taskX + tailW, boxY + bubbleH) - c.lineTo(taskX, boxY + bubbleH + 4 * zoom) - c.lineTo(taskX - tailW, boxY + bubbleH) + 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) - c.lineTo(boxX, boxY + r) - c.arcTo(boxX, boxY, boxX + r, boxY, 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() @@ -624,7 +637,6 @@ export function renderScene( c.lineWidth = zoom c.stroke() - // Clip to bubble interior and draw scrolling text c.beginPath() c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH) c.clip() From 8531b69a767885718bf7cee7d668597552d3caa9 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Thu, 12 Mar 2026 22:16:52 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E8=BE=A6=E5=85=AC=E5=AE=A4=E7=9A=84=E6=A9=9F=E6=AB=83=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81=E7=B7=A8=E8=BC=AF=E7=95=AB=E9=9D=A2=E3=80=81?= =?UTF-8?q?=E8=87=A8=E6=99=82=E5=B7=A5=E5=8F=8AAgent=E9=80=B2=E5=A0=B4?= =?UTF-8?q?=E8=88=87=E5=87=BA=E5=A0=B4=E8=A1=8C=E7=82=BA=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/agent-activity/route.ts | 67 ++- app/sidebar.tsx | 7 +- lib/i18n.tsx | 2 +- lib/pixel-office/engine/characters.ts | 40 +- lib/pixel-office/engine/officeState.ts | 502 +++++++++++++++++++- lib/pixel-office/layout/layoutSerializer.ts | 18 +- lib/pixel-office/types.ts | 3 + lib/pixel-office/wallTiles.ts | 2 +- 8 files changed, 594 insertions(+), 47 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index b1fa779..16d5041 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 @@ -115,11 +115,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 +128,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 +544,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 +558,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 +602,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 +631,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 +662,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) { diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 367056b..ccfe303 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -474,14 +474,15 @@ export function Sidebar() { 🦞
-
- OPENCLAW{mobileOpenclawVersion ? ` ${mobileOpenclawVersion}` : ""} -
+
OPENCLAW
{pathname === "/" && mobileAgentCount !== null ? `${mobileAgentCount} ${t("home.agentCount")}` : mobileCurrent ? t(mobileCurrent.labelKey) : "BOT DASHBOARD"}
+ {mobileOpenclawVersion && ( +
v{mobileOpenclawVersion}
+ )}
diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 5e960fd..02dd9cf 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -261,7 +261,7 @@ const translations: Record> = { "pixelOffice.sound": "音效", "pixelOffice.resetView": "重設視圖", "pixelOffice.state.working": "工作中", - "pixelOffice.state.idle": "摸魚中", + "pixelOffice.state.idle": "休息中", "pixelOffice.state.offline": "下班了", "pixelOffice.state.waiting": "等待中", "pixelOffice.tempWorker": "臨時工", diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 21e4c60..2d284a2 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -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, @@ -145,6 +147,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 +263,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 +366,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) + if (ch.isActive && ch.seatId && !ch.greetLocked) { const seat = seats.get(ch.seatId) if (seat) { const lastStep = ch.path[ch.path.length - 1] diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index f758f91..4abf616 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -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,19 @@ export interface GatewaySreInfo { checkedAt: number | null } +interface GreetingSequence { + childId: number + parentId: number + childTarget: { 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 +} + export class OfficeState { layout: OfficeLayout tileMap: TileTypeVal[][] @@ -417,6 +430,17 @@ export class OfficeState { private gatewaySreResponseMs: number | null = null private gatewaySreCheckedAt: number | null = null private locale: OfficeLocale = 'zh' + private activeGreetings: Map = 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 = new Map() + /** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */ + private exitOnGreetComplete: Set = new Set() + /** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */ + private exitReturnStash: Map = new Map() getTempWorkerLabel(): string { return getTempWorkerLabel(this.locale) @@ -709,6 +733,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 +879,245 @@ 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 + } + + /** 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 + } + const 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 + 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, + 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 } + + // Detected arrival: child tile matches target + if (child.tileCol === target.col && child.tileRow === target.row) { + // Override any FSM re-path + 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) + } + seq.phase = 'pause' + seq.timer = 1.0 + } else if (child.path.length === 0 && child.state !== CharacterState.WALK) { + // 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 { + // Can't reach — complete without greeting + 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 + const walkBack = this.findExitWalkPath(child, seq.exitReturnPos) + if (walkBack) { + child.path = walkBack.path + child.state = CharacterState.WALK + child.moveProgress = 0 + child.pendingDespawn = walkBack.target + } else { + 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 +1133,69 @@ 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 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 +1214,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 +1620,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 +1637,73 @@ 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 + // 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 } - // Clean up tracking maps immediately so keys don't collide this.subagentIdMap.delete(key) this.subagentMeta.delete(id) - if (this.selectedAgentId === id) this.selectedAgentId = null - if (this.cameraFollowId === id) this.cameraFollowId = null + } + + /** 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 back directly then despawn + const walkBack = this.findExitWalkPath(ch, exitReturnPos) + if (walkBack) { + ch.pendingDespawn = walkBack.target + ch.path = walkBack.path + ch.state = CharacterState.WALK + ch.moveProgress = 0 + } else { + ch.matrixEffect = 'despawn' + ch.matrixEffectTimer = 0 + ch.matrixEffectSeeds = matrixEffectSeeds() + } + } } /** Remove all sub-agents belonging to a parent agent */ @@ -1332,20 +1715,58 @@ 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 + } + 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, + 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 + } } - // Start despawn animation 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 +1821,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() } } @@ -1553,10 +1978,11 @@ export class OfficeState { continue } - if (ch.systemRoleType === 'gateway_sre') { + 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) ) @@ -1641,6 +2067,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) diff --git a/lib/pixel-office/layout/layoutSerializer.ts b/lib/pixel-office/layout/layoutSerializer.ts index 67030a1..348f15b 100644 --- a/lib/pixel-office/layout/layoutSerializer.ts +++ b/lib/pixel-office/layout/layoutSerializer.ts @@ -236,16 +236,17 @@ const RIGHT_WALL_STOOLS: ReadonlyArray = [ { 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 = { - uid: 'server-b-left', +const RIGHT_WALL_SERVER: Readonly = { + 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 }, diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index 7a57325..bd0e8b9 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -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,6 @@ export interface Character { isSystemRole?: boolean systemRoleType?: 'gateway_sre' systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' + /** Walk back to this tile after farewell greeting, then despawn */ + pendingDespawn?: { col: number; row: number } | true } diff --git a/lib/pixel-office/wallTiles.ts b/lib/pixel-office/wallTiles.ts index e8ec8b1..bcc95a6 100644 --- a/lib/pixel-office/wallTiles.ts +++ b/lib/pixel-office/wallTiles.ts @@ -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, }) } } From 45ca4029841d91efe1415f6b861a7397103fb0ab Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 13 Mar 2026 22:39:52 +0800 Subject: [PATCH 12/18] =?UTF-8?q?Agent=E4=BE=9D=E7=85=A7=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E4=B8=AD=E3=80=81=E4=BC=91=E6=81=AF=E4=B8=AD=E3=80=81=E4=B8=8B?= =?UTF-8?q?=E7=8F=AD=E4=BA=86=E6=8E=92=E5=BA=8F=EF=BC=9B=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=95=9F=E5=8B=95Gateway=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + app/api/gateway-logs/route.ts | 51 ++++++++++ app/api/gateway-restart/route.ts | 62 ++++++++++++ app/gateway-status.tsx | 169 ++++++++++++++++++++++++++++--- app/pixel-office/page.tsx | 10 +- 5 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 app/api/gateway-logs/route.ts create mode 100644 app/api/gateway-restart/route.ts diff --git a/.gitignore b/.gitignore index 5eb7554..056f64c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ next-env.d.ts .idea .DS_Store /public/assets/pixel-office/*.mp3 +.env.local +.env.*.local diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts new file mode 100644 index 0000000..4a27565 --- /dev/null +++ b/app/api/gateway-logs/route.ts @@ -0,0 +1,51 @@ +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; + + return NextResponse.json({ + ok: true, + issues: [...issues], + lastStallAt, + stallActive, + }); + } 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/gateway-status.tsx b/app/gateway-status.tsx index dc24149..e0ea97a 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -20,6 +20,13 @@ interface HealthResult { openclawVersion?: string; } +interface LogResult { + ok: boolean; + issues: string[]; + lastStallAt: string | null; + stallActive: boolean; +} + interface GatewayStatusProps { compact?: boolean; className?: string; @@ -29,30 +36,81 @@ 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(() => { + const checkHealth = useCallback(() => { fetch("/api/gateway-health") .then((r) => r.json()) - .then((d) => setHealth(d)) + .then((d: HealthResult) => { + setHealth(d); + // If health is down, also fetch logs for more context + if (!d.ok) fetchLogs(); + }) .catch(() => setHealth({ ok: false, error: t("gateway.fetchError") })); }, [t]); + const fetchLogs = useCallback(() => { + fetch("/api/gateway-logs") + .then((r) => r.json()) + .then((d: LogResult) => setLogResult(d)) + .catch(() => {}); + }, []); + useEffect(() => { - check(); - const timer = setInterval(check, 10000); + checkHealth(); + const timer = setInterval(checkHealth, 10000); return () => clearInterval(timer); - }, [check]); + }, [checkHealth]); + + const handleDetailClick = useCallback(() => { + setShowDetail((v) => !v); + // Fetch fresh logs whenever the user opens the detail panel + fetchLogs(); + }, [fetchLogs]); + + 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 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; + 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} +
+ )} + + {/* Restart result message */} + {restartMsg && ( +
+ {restartMsg} +
+ )} +
+ + {/* Restart button */} +
+ +
)}
diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 7db4610..d1565ee 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1180,12 +1180,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 +1654,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]) From 039bbf8a7fa8846a356232f682aaa1552fb5b676 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sat, 14 Mar 2026 07:26:52 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E8=A1=8C=E5=8B=95?= =?UTF-8?q?=E8=B7=AF=E7=B7=9A=E7=AD=96=E7=95=A5=EF=BC=8C=E6=B8=9B=E5=B0=91?= =?UTF-8?q?=E7=A2=B0=E6=92=9E=E6=A9=9F=E6=9C=83=EF=BC=88=E9=82=84=E6=98=AF?= =?UTF-8?q?=E6=9C=83=E7=A2=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pixel-office/page.tsx | 34 +++++ lib/pixel-office/engine/characters.ts | 6 +- lib/pixel-office/engine/officeState.ts | 182 +++++++++++++++++++++++++ lib/pixel-office/types.ts | 4 + 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index d1565ee..abf7e49 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -892,6 +892,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 { diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 2d284a2..c8ea8d7 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -103,6 +103,8 @@ export function createCharacter( codeSnippets: [], photoComments: [], isViewingPhoto: false, + yieldTimer: 0, + yieldDestination: null, } } @@ -366,8 +368,8 @@ export function updateCharacter( ch.moveProgress = 0 } - // If became active while wandering, repath to seat (skip if under greeting control) - if (ch.isActive && ch.seatId && !ch.greetLocked) { + // 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] diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 4abf616..7f1568a 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1950,9 +1950,165 @@ 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, + occupiedKeys: Set, + claimedNext: Map, + ): { 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() + for (const ch of this.characters.values()) { + occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`) + } + + const walkableSet = new Set(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() + 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 + 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() + 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 + 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()) { @@ -1978,6 +2134,24 @@ export class OfficeState { continue } + // 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 { @@ -1988,6 +2162,14 @@ export class OfficeState { ) } + // 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) diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index bd0e8b9..e59582b 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -214,4 +214,8 @@ export interface Character { systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' /** 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 } From 9a4b19afb63124837e372cf055f0aed61b52c499 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 00:04:02 +0800 Subject: [PATCH 14/18] feat: improve agent state detection and pixel office greeting logic - Detect agent state by reading session JSONL files instead of fixed time thresholds - Add detectStateFromSession() to determine idle/working/offline states accurately - Fix pixel office: only show 'online' broadcast when agent returns from offline (not idle) - Add meeting point logic: subagent meets MainAgent at midpoint when MainAgent is not at seat --- app/api/agent-activity/route.ts | 103 +++++++++++++++++++++++-- app/pixel-office/page.tsx | 3 +- lib/pixel-office/engine/officeState.ts | 102 ++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 15 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 16d5041..62a9204 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -821,6 +821,58 @@ async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronSto return cronJobs } +/** + * 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'> { + const OFFLINE_MS = 10 * 60 * 1000 + const timeDiff = now - lastActive + if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + try { + const content = await fs.readFile(sessionFilePath, 'utf8') + const lines = content.split('\n').filter(l => l.trim()).slice(-30) + + 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 */ } + } + + // User message is the last written record → agent is generating a response right now + if (lastRole === 'user') return 'working' + // toolResult as the latest message → agent is processing the tool output + if (lastRole === 'toolResult') return 'working' + // Agent called a tool and is waiting for the result + if (lastStopReason === 'toolUse') return 'working' + // Agent completed its turn — it's now idle + if (lastStopReason === 'stop') return 'idle' + } catch { /* file unreadable — fall through */ } + + // Fallback: time-based (no session content available) + return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' +} + export async function GET() { const configPath = OPENCLAW_CONFIG_PATH const agentsDir = OPENCLAW_AGENTS_DIR @@ -839,34 +891,69 @@ export async function GET() { for (const agent of agentList) { let lastActive = 0 + let mostRecentSessionFile: string | null = null let agentSessionsDir = '' 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 */ } + + // Prefer the sessions.json-mapped file over raw scan when available + // (ensures we read a proper session, not a probe or temp file) + if (mostRecentSessionFile) { + const sessionId = path.basename(mostRecentSessionFile, '.jsonl') + if (!sessionIdToFile.has(sessionId)) { + // Most-recent file is not in sessions.json — find best known session by mtime + 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 */ } } } - } catch { - // Ignore } } } + // 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 diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index abf7e49..36259ec 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) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 7f1568a..f5b45a5 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -390,6 +390,8 @@ 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' @@ -439,6 +441,8 @@ export class OfficeState { private lingerSubagents: Map = new Map() /** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */ private exitOnGreetComplete: Set = new Set() + /** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */ + private explicitlyIdledAgents: Set = new Set() /** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */ private exitReturnStash: Map = new Map() @@ -893,6 +897,41 @@ export class OfficeState { 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 @@ -935,7 +974,25 @@ export class OfficeState { this.greetQueue.length = 0 return } - const greetTile = this.findAdjacentWalkable(mainCh) + // 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 } @@ -944,6 +1001,20 @@ export class OfficeState { 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 @@ -953,6 +1024,7 @@ export class OfficeState { childId: ch.id, parentId: this.mainAgentId!, childTarget: greetTile, + parentTarget, waitTarget: null, phase: 'walk', timer: 0, @@ -989,9 +1061,27 @@ export class OfficeState { const target = seq.childTarget if (!target) { seq.phase = 'complete'; break } - // Detected arrival: child tile matches target - if (child.tileCol === target.col && child.tileRow === target.row) { - // Override any FSM re-path + // 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 @@ -1005,14 +1095,13 @@ export class OfficeState { seq.phase = 'pause' seq.timer = 1.0 } else if (child.path.length === 0 && child.state !== CharacterState.WALK) { - // Lost path mid-walk — try to re-path to target + // 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 { - // Can't reach — complete without greeting seq.phase = 'complete' } } @@ -1751,6 +1840,7 @@ export class OfficeState { childId: id, parentId: parentAgentId, childTarget: greetTile, + parentTarget: null, waitTarget: null, phase: 'walk', timer: 0, From 64cb5267585f3fe1162a0b536d3cdc229fc97fbf Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 11:31:53 +0800 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20=E5=83=8F=E7=B4=A0=E8=BE=A6?= =?UTF-8?q?=E5=85=AC=E5=AE=A4=E9=A1=AF=E7=A4=BA=20Agent=20=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E5=9F=B7=E8=A1=8C=E7=9A=84=E4=BA=A4=E8=BE=A6=E4=BB=BB?= =?UTF-8?q?=E5=8B=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-activity API 新增 lastTask 欄位,回傳 working 中 Agent 最後被交辦的任務內容 - 支援兩種格式:Telegram 訊息(``` fence 後的文字)與子 Agent spawn([Subagent Task]: 內容) - Character 新增 taskText 欄位,OfficeState 新增 setAgentTaskText() 方法 - agentBridge 同步任務文字至像素辦公室角色 - renderer 在 working 中的 Agent 頭上顯示固定寬度氣泡,內容過長自動跑馬燈捲動 Co-Authored-By: Claude Sonnet 4.6 --- app/api/agent-activity/route.ts | 91 ++++++++++++++++++++++++++ lib/pixel-office/agentBridge.ts | 9 ++- lib/pixel-office/engine/officeState.ts | 7 ++ lib/pixel-office/engine/renderer.ts | 78 ++++++++++++++++++++++ lib/pixel-office/types.ts | 2 + 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 62a9204..ca9c1d6 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -68,6 +68,7 @@ export interface AgentActivity { lastActive: number subagents?: SubagentInfo[] cronJobs?: CronJobInfo[] + lastTask?: string } type AgentConfigEntry = { @@ -821,6 +822,89 @@ 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. * @@ -969,6 +1053,12 @@ 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, @@ -977,6 +1067,7 @@ export async function GET() { lastActive, subagents, cronJobs, + lastTask, }) } } diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index 16feb5c..424429d 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -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) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index f5b45a5..36ebbe5 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1986,6 +1986,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) { diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 11d6893..1f00ed1 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -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,68 @@ export function renderScene( }) } + // Task text bubble: scrolling marquee above the agent's head when working + 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) // fixed width window + const boxX = taskX - bubbleW / 2 + const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + 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 + // Scroll speed: pixels per ms. Pause at start/end with a gap. + const gap = bubbleW * 0.5 + const cycle = fullW + gap + const speed = 30 // px/s at zoom=1, scaled below + const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) + const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) + + // Bubble background + const r = 3 * 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) + // Tail pointing down + const tailW = 5 * zoom + 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) + 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() + + // Clip to bubble interior and draw scrolling text + 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. } diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index e59582b..e2c5829 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -212,6 +212,8 @@ 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 */ From 8dcbe98c6e61941a581840ba8bff38fc7c8a1513 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 20 Mar 2026 10:42:35 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E8=AA=BF=E6=95=B4pix=20office=20?= =?UTF-8?q?=E8=A1=8C=E7=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/agent-activity/route.ts | 55 +++++++++++++++---- app/api/config/route.ts | 22 +++++++- app/globals.css | 3 ++ app/pixel-office/page.tsx | 26 ++++----- lib/pixel-office/engine/officeState.ts | 75 +++++++++++++++++++------- lib/pixel-office/engine/renderer.ts | 42 +++++++++------ 6 files changed, 162 insertions(+), 61 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index ca9c1d6..26cf725 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -919,9 +919,14 @@ async function detectStateFromSession( now: number, lastActive: number, ): Promise<'idle' | 'working' | 'offline'> { - const OFFLINE_MS = 10 * 60 * 1000 + 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 - if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + // Last activity > 10 min ago → offline regardless of session content + if (timeDiff > OFFLINE_MS) return 'offline' try { const content = await fs.readFile(sessionFilePath, 'utf8') @@ -943,17 +948,15 @@ async function detectStateFromSession( } catch { /* skip malformed line */ } } - // User message is the last written record → agent is generating a response right now - if (lastRole === 'user') return 'working' - // toolResult as the latest message → agent is processing the tool output - if (lastRole === 'toolResult') return 'working' - // Agent called a tool and is waiting for the result - if (lastStopReason === 'toolUse') return 'working' - // Agent completed its turn — it's now idle + // Work completed → immediately idle if (lastStopReason === 'stop') return 'idle' + // Still processing — but cap at 10 min, after that force idle + if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') { + return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle' + } } catch { /* file unreadable — fall through */ } - // Fallback: time-based (no session content available) + // Fallback within the 10-min window return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' } @@ -978,6 +981,36 @@ export async function GET() { 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)) { @@ -1062,7 +1095,7 @@ export async function GET() { 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, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 33765b3..d4a53f4 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -231,6 +231,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, @@ -350,7 +369,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); // 查找绑定的平台 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/pixel-office/page.tsx b/app/pixel-office/page.tsx index 36259ec..cbc61d2 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1814,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 && ( @@ -1858,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/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 36ebbe5..ccf61eb 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -399,6 +399,8 @@ interface GreetingSequence { 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 { @@ -1091,6 +1093,14 @@ export class OfficeState { 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 @@ -1169,13 +1179,8 @@ export class OfficeState { if (seq.isExit) { child.bubbleType = null - const walkBack = this.findExitWalkPath(child, seq.exitReturnPos) - if (walkBack) { - child.path = walkBack.path - child.state = CharacterState.WALK - child.moveProgress = 0 - child.pendingDespawn = walkBack.target - } else { + // 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() @@ -1231,6 +1236,38 @@ export class OfficeState { 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. @@ -1780,14 +1817,8 @@ export class OfficeState { // Stash the return position until the greeting actually starts if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos) } else { - // No MainAgent reachable — walk back directly then despawn - const walkBack = this.findExitWalkPath(ch, exitReturnPos) - if (walkBack) { - ch.pendingDespawn = walkBack.target - ch.path = walkBack.path - ch.state = CharacterState.WALK - ch.moveProgress = 0 - } else { + // No MainAgent reachable — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { ch.matrixEffect = 'despawn' ch.matrixEffectTimer = 0 ch.matrixEffectSeeds = matrixEffectSeeds() @@ -1854,9 +1885,12 @@ export class OfficeState { continue } } - ch.matrixEffect = 'despawn' - ch.matrixEffectTimer = 0 - ch.matrixEffectSeeds = matrixEffectSeeds() + // No greeting path — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { + ch.matrixEffect = 'despawn' + ch.matrixEffectTimer = 0 + ch.matrixEffectSeeds = matrixEffectSeeds() + } } this.subagentMeta.delete(id) if (this.selectedAgentId === id) this.selectedAgentId = null @@ -2144,6 +2178,9 @@ export class OfficeState { 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) @@ -2192,6 +2229,8 @@ export class OfficeState { 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}`) diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 1f00ed1..169e82c 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -594,7 +594,7 @@ export function renderScene( }) } - // Task text bubble: scrolling marquee above the agent's head when working + // 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)) @@ -602,9 +602,13 @@ export function renderScene( const padX = 5 * zoom const padY = 3 * zoom const bubbleH = taskFontSize + padY * 2 - const bubbleW = Math.round(72 * zoom) // fixed width window - const boxX = taskX - bubbleW / 2 - const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + 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, @@ -612,30 +616,39 @@ export function renderScene( c.save() c.font = `${taskFontSize}px sans-serif` const fullW = c.measureText(fullText).width - // Scroll speed: pixels per ms. Pause at start/end with a gap. const gap = bubbleW * 0.5 const cycle = fullW + gap - const speed = 30 // px/s at zoom=1, scaled below + const speed = 30 const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) - // Bubble background 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) - // Tail pointing down - const tailW = 5 * zoom - c.lineTo(taskX + tailW, boxY + bubbleH) - c.lineTo(taskX, boxY + bubbleH + 4 * zoom) - c.lineTo(taskX - tailW, boxY + bubbleH) + 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) - c.lineTo(boxX, boxY + r) - c.arcTo(boxX, boxY, boxX + r, boxY, 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() @@ -643,7 +656,6 @@ export function renderScene( c.lineWidth = zoom c.stroke() - // Clip to bubble interior and draw scrolling text c.beginPath() c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH) c.clip() From 239b357e575de29131c6e3decc1bd10859c13dff Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 20 Mar 2026 11:04:19 +0800 Subject: [PATCH 17/18] feat: auto-detect browser locale (zh-TW / zh / en) Falls back to navigator.languages when no saved preference exists in localStorage. Manual language selection still takes priority. Co-Authored-By: Claude Sonnet 4.6 --- lib/i18n.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 02dd9cf..7d95338 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -883,6 +883,17 @@ const I18nContext = createContext({ 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("zh"); @@ -890,6 +901,8 @@ export function I18nProvider({ children }: { children: ReactNode }) { const saved = localStorage.getItem("locale") as Locale; if (saved && (saved === "zh-TW" || saved === "zh" || saved === "en")) { setLocaleState(saved); + } else { + setLocaleState(detectBrowserLocale()); } }, []); From 51742576d5792ae9e2998ae39b002b1b3180ac68 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 22 Mar 2026 09:47:54 +0800 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20=E6=94=B9=E5=96=84=20agent=20?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E5=81=B5=E6=B8=AC=E6=BA=96=E7=A2=BA=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session 讀取行數從 30 增加至 50,減少漏判 - idle 判斷改為需同時滿足 lastRole=assistant + stopReason=stop,避免誤判 - 新增 mid-generation(streaming)狀態偵測 - 新 session 檔案(5 分鐘內)不再被 sessions.json 索引強制取代 Co-Authored-By: Claude Sonnet 4.6 --- app/api/agent-activity/route.ts | 40 ++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 5569505..56b72ba 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -669,7 +669,7 @@ async function detectStateFromSession( try { const content = await fs.readFile(sessionFilePath, 'utf8') - const lines = content.split('\n').filter(l => l.trim()).slice(-30) + const lines = content.split('\n').filter(l => l.trim()).slice(-50) let lastRole: string | null = null let lastStopReason: string | null = null @@ -687,12 +687,16 @@ async function detectStateFromSession( } catch { /* skip malformed line */ } } - // Work completed → immediately idle - if (lastStopReason === 'stop') return 'idle' - // Still processing — but cap at 10 min, after that force idle + // 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 @@ -782,19 +786,29 @@ export async function GET() { } } catch { /* ignore */ } - // Prefer the sessions.json-mapped file over raw scan when available - // (ensures we read a proper session, not a probe or temp file) + // 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)) { - // Most-recent file is not in sessions.json — find best known session by mtime - 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 */ } + 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 } } }