調整pix office 行為

This commit is contained in:
Dan Lee
2026-03-20 10:55:14 +08:00
parent 64cb526758
commit 8dcbe98c6e
6 changed files with 162 additions and 61 deletions
+44 -11
View File
@@ -919,9 +919,14 @@ async function detectStateFromSession(
now: number,
lastActive: number,
): Promise<'idle' | 'working' | 'offline'> {
const OFFLINE_MS = 10 * 60 * 1000
if (lastActive === 0) return 'offline'
const OFFLINE_MS = 10 * 60 * 1000 // idle > 10 min → offline
const WORKING_MAX_MS = 10 * 60 * 1000 // working > 10 min → force idle
const timeDiff = now - lastActive
if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline'
// Last activity > 10 min ago → offline regardless of session content
if (timeDiff > OFFLINE_MS) return 'offline'
try {
const content = await fs.readFile(sessionFilePath, 'utf8')
@@ -943,17 +948,15 @@ async function detectStateFromSession(
} catch { /* skip malformed line */ }
}
// User message is the last written record → agent is generating a response right now
if (lastRole === 'user') return 'working'
// toolResult as the latest message → agent is processing the tool output
if (lastRole === 'toolResult') return 'working'
// Agent called a tool and is waiting for the result
if (lastStopReason === 'toolUse') return 'working'
// Agent completed its turn — it's now idle
// Work completed → immediately idle
if (lastStopReason === 'stop') return 'idle'
// Still processing — but cap at 10 min, after that force idle
if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') {
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
}
} catch { /* file unreadable — fall through */ }
// Fallback: time-based (no session content available)
// Fallback within the 10-min window
return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle'
}
@@ -978,6 +981,36 @@ export async function GET() {
let mostRecentSessionFile: string | null = null
let agentSessionsDir = ''
// Resolve emoji: IDENTITY.md > agent.json > openclaw.json > default
let agentJsonEmoji: string | undefined
if (existsSync(agentsDir)) {
// 1. Read from workspace IDENTITY.md ("- **Emoji:** 🌸")
const workspaceDir = typeof (agent as any).workspace === 'string' ? (agent as any).workspace : null
if (workspaceDir) {
const identityPath = path.join(workspaceDir, 'IDENTITY.md')
if (existsSync(identityPath)) {
try {
const identityRaw = await fs.readFile(identityPath, 'utf8')
const m = identityRaw.match(/\*\*Emoji:\*\*\s*(\S+)/)
if (m?.[1]) agentJsonEmoji = m[1]
} catch { /* ignore */ }
}
}
// 2. Fallback: read from agent's agent.json emoji field
if (!agentJsonEmoji) {
const agentJsonPath = path.join(agentsDir, agent.id, 'agent', 'agent.json')
if (existsSync(agentJsonPath)) {
try {
const raw = await fs.readFile(agentJsonPath, 'utf8')
const parsed = JSON.parse(raw)
if (typeof parsed?.emoji === 'string' && parsed.emoji.trim()) {
agentJsonEmoji = parsed.emoji.trim()
}
} catch { /* ignore */ }
}
}
}
if (existsSync(agentsDir)) {
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
if (existsSync(agentSessionsDir)) {
@@ -1062,7 +1095,7 @@ export async function GET() {
agents.push({
agentId: agent.id,
name: agent.name || agent.id,
emoji: agent.identity?.emoji || agent.emoji || '🤖',
emoji: agentJsonEmoji || agent.identity?.emoji || agent.emoji || '🤖',
state,
lastActive,
subagents,
+21 -1
View File
@@ -231,6 +231,25 @@ function getChannelDirectPeerIds(
return map;
}
// 从 IDENTITY.md 读取机器人名字
function readIdentityEmoji(agentId: string, agentDir?: string, workspace?: string): string | null {
const candidates = [
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
workspace ? path.join(workspace, "IDENTITY.md") : null,
path.join(OPENCLAW_DIR, `agents/${agentId}/agent/IDENTITY.md`),
path.join(OPENCLAW_DIR, `workspace-${agentId}/IDENTITY.md`),
agentId === "main" ? path.join(OPENCLAW_DIR, `workspace/IDENTITY.md`) : null,
].filter(Boolean) as string[];
for (const p of candidates) {
try {
const content = fs.readFileSync(p, "utf-8");
const match = content.match(/\*\*Emoji:\*\*\s*(\S+)/);
if (match?.[1]) return match[1].trim();
} catch {}
}
return null;
}
function readIdentityName(agentId: string, agentDir?: string, workspace?: string): string | null {
const candidates = [
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
@@ -350,7 +369,8 @@ export async function GET() {
const id = agent.id;
const identityName = readIdentityName(id, agent.agentDir, agent.workspace);
const name = identityName || agent.name || id;
const emoji = agent.identity?.emoji || "🤖";
const identityEmoji = readIdentityEmoji(id, agent.agentDir, agent.workspace);
const emoji = identityEmoji || agent.identity?.emoji || agent.emoji || "🤖";
const model = normalizeModelRef(agent.model, defaultModel);
// 查找绑定的平台
+3
View File
@@ -134,3 +134,6 @@ body {
border-top: 1px solid var(--border);
padding: 12px 16px;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+10 -16
View File
@@ -1814,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 && (
@@ -1858,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">
+57 -18
View File
@@ -399,6 +399,8 @@ interface GreetingSequence {
isExit: boolean
/** Tile to walk back to after farewell, before despawning */
exitReturnPos: { col: number; row: number } | null
/** True when child arrived at greeting tile but MainAgent wasn't physically present */
parentAbsent?: boolean
}
export class OfficeState {
@@ -1091,6 +1093,14 @@ export class OfficeState {
parent.state = CharacterState.IDLE
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
// Detect if parent is not physically nearby (wandered away from greeting tile)
const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow)
if (seq.isExit && distToParent > 3) {
seq.parentAbsent = true
}
} else {
// Parent character doesn't exist
if (seq.isExit) seq.parentAbsent = true
}
seq.phase = 'pause'
seq.timer = 1.0
@@ -1169,13 +1179,8 @@ export class OfficeState {
if (seq.isExit) {
child.bubbleType = null
const walkBack = this.findExitWalkPath(child, seq.exitReturnPos)
if (walkBack) {
child.path = walkBack.path
child.state = CharacterState.WALK
child.moveProgress = 0
child.pendingDespawn = walkBack.target
} else {
// After farewell (whether MainAgent was present or not), walk to bottom-right corner
if (!this.walkToBottomRightThenDespawn(child)) {
child.matrixEffect = 'despawn'
child.matrixEffectTimer = 0
child.matrixEffectSeeds = matrixEffectSeeds()
@@ -1231,6 +1236,38 @@ export class OfficeState {
return this.findClosestWalkable(sofa.col, sofa.row)
}
/** Find the walkable tile closest to the bottom-right corner of the map */
private findBottomRightCornerTile(): { col: number; row: number } | null {
if (this.walkableTiles.length === 0) return null
// Bottom-right in tile space = max col + max row
const maxCol = Math.max(...this.walkableTiles.map((t) => t.col))
const maxRow = Math.max(...this.walkableTiles.map((t) => t.row))
return this.findClosestWalkable(maxCol, maxRow)
}
/**
* Walk character to the bottom-right corner then despawn.
* Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move.
*/
private walkToBottomRightThenDespawn(ch: Character): boolean {
const corner = this.findBottomRightCornerTile()
if (!corner) return false
if (corner.col === ch.tileCol && corner.row === ch.tileRow) {
// Already there — despawn directly
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
return true
}
const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles)
if (path.length === 0) return false
ch.path = path
ch.state = CharacterState.WALK
ch.moveProgress = 0
ch.pendingDespawn = corner
return true
}
/**
* Find a walk-back path for a departing temp worker.
* Ensures the path is long enough that the worker visibly walks away from MainAgent.
@@ -1780,14 +1817,8 @@ export class OfficeState {
// Stash the return position until the greeting actually starts
if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos)
} else {
// No MainAgent reachable — walk back directly then despawn
const walkBack = this.findExitWalkPath(ch, exitReturnPos)
if (walkBack) {
ch.pendingDespawn = walkBack.target
ch.path = walkBack.path
ch.state = CharacterState.WALK
ch.moveProgress = 0
} else {
// No MainAgent reachable — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
@@ -1854,9 +1885,12 @@ export class OfficeState {
continue
}
}
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
// No greeting path — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
}
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
@@ -2144,6 +2178,9 @@ export class OfficeState {
if (dodgeTile) {
ch.path = [dodgeTile]
ch.moveProgress = 0
// Face the dodge direction immediately so the character doesn't appear to
// move backward while still facing forward.
ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row)
ch.yieldDestination = { col: dest.col, row: dest.row }
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id)
@@ -2192,6 +2229,8 @@ export class OfficeState {
dodger.state = CharacterState.WALK
dodger.frame = 0
dodger.frameTimer = 0
// Face the dodge direction immediately
dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id)
occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`)
occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`)
+27 -15
View File
@@ -594,7 +594,7 @@ export function renderScene(
})
}
// Task text bubble: scrolling marquee above the agent's head when working
// Task text bubble: scrolling marquee above (or below if near top) the agent's head
if (ch.taskText && ch.isActive && ch.state === CharacterState.TYPE && !ch.isSubagent) {
const taskX = Math.round(offsetX + ch.x * zoom)
const labelFontSize = Math.max(12, Math.round(5.25 * zoom))
@@ -602,9 +602,13 @@ export function renderScene(
const padX = 5 * zoom
const padY = 3 * zoom
const bubbleH = taskFontSize + padY * 2
const bubbleW = Math.round(72 * zoom) // fixed width window
const boxX = taskX - bubbleW / 2
const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom
const bubbleW = Math.round(72 * zoom)
const boxX = Math.max(2, Math.min(taskX - bubbleW / 2, (ctx.canvas.width - bubbleW - 2)))
const idealBoxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom
// If bubble would be clipped by top edge, flip it below the character instead
const belowY = drawY + cached.height + labelFontSize + 4 * zoom
const tailUp = idealBoxY >= 4 // tail points down when bubble is above, up when below
const boxY = idealBoxY >= 4 ? idealBoxY : belowY
const fullText = ch.taskText
drawables.push({
zY: charZY + 0.2,
@@ -612,30 +616,39 @@ export function renderScene(
c.save()
c.font = `${taskFontSize}px sans-serif`
const fullW = c.measureText(fullText).width
// Scroll speed: pixels per ms. Pause at start/end with a gap.
const gap = bubbleW * 0.5
const cycle = fullW + gap
const speed = 30 // px/s at zoom=1, scaled below
const speed = 30
const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle)
const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0)
// Bubble background
const r = 3 * zoom
const tailW = 5 * zoom
c.beginPath()
c.moveTo(boxX + r, boxY)
c.lineTo(boxX + bubbleW - r, boxY)
c.arcTo(boxX + bubbleW, boxY, boxX + bubbleW, boxY + r, r)
c.lineTo(boxX + bubbleW, boxY + bubbleH - r)
c.arcTo(boxX + bubbleW, boxY + bubbleH, boxX + bubbleW - r, boxY + bubbleH, r)
// Tail pointing down
const tailW = 5 * zoom
c.lineTo(taskX + tailW, boxY + bubbleH)
c.lineTo(taskX, boxY + bubbleH + 4 * zoom)
c.lineTo(taskX - tailW, boxY + bubbleH)
if (tailUp) {
// Tail at bottom pointing down toward label
c.lineTo(taskX + tailW, boxY + bubbleH)
c.lineTo(taskX, boxY + bubbleH + 4 * zoom)
c.lineTo(taskX - tailW, boxY + bubbleH)
}
c.lineTo(boxX + r, boxY + bubbleH)
c.arcTo(boxX, boxY + bubbleH, boxX, boxY + bubbleH - r, r)
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
if (!tailUp) {
// Tail at top pointing up toward character
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
c.lineTo(taskX - tailW, boxY)
c.lineTo(taskX, boxY - 4 * zoom)
c.lineTo(taskX + tailW, boxY)
} else {
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
}
c.closePath()
c.fillStyle = 'rgba(15,23,42,0.88)'
c.fill()
@@ -643,7 +656,6 @@ export function renderScene(
c.lineWidth = zoom
c.stroke()
// Clip to bubble interior and draw scrolling text
c.beginPath()
c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH)
c.clip()