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