feat(pixel-office): sync and render subagents with mobile/desktop UI updates

This commit is contained in:
xmanrui
2026-03-04 03:57:23 +08:00
parent 253b585466
commit 1c5394f6e4
7 changed files with 486 additions and 99 deletions
+215 -44
View File
@@ -3,9 +3,17 @@ import { promises as fs, existsSync } from 'fs'
import path from 'path'
import os from 'os'
export const dynamic = 'force-dynamic'
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
export interface SubagentInfo {
toolId: string
label: string
sessionKey?: string
}
export interface AgentActivity {
@@ -19,70 +27,147 @@ export interface AgentActivity {
subagents?: SubagentInfo[]
}
/** Parse the last N lines of the most recent session file for subtask patterns */
async function parseSubagents(agentSessionsDir: string): Promise<SubagentInfo[]> {
function isSubtaskDescription(desc: string): boolean {
const d = desc.toLowerCase()
return desc.startsWith('Subtask:') || desc.startsWith('子任务') || d.includes('subtask')
}
function isSpawnTool(name: string): boolean {
return name === 'sessions_spawn' || name === 'session_spawn'
}
function pickSubagentLabel(raw: unknown): string {
if (!raw || typeof raw !== 'object') return 'Subtask'
const args = raw as Record<string, unknown>
if (typeof args.label === 'string' && args.label.trim()) return args.label.trim()
if (typeof args.task === 'string' && args.task.trim()) return args.task.trim()
if (typeof args.description === 'string' && args.description.trim()) return args.description.trim()
return 'Subtask'
}
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,
/subagent task\s+"([^"]+)"\s+.*completed/i,
/subagent task\s+'([^']+)'\s+.*completed/i,
/子任务[“"]([^”"]+)[”"].{0,12}完成/,
]
for (const p of patterns) {
const m = text.match(p)
if (m?.[1]?.trim()) return m[1].trim()
}
return null
}
function parseRecordTimestamp(record: unknown): number {
if (!record || typeof record !== 'object') return 0
const rec = record as Record<string, unknown>
if (typeof rec.timestamp === 'string') {
const t = Date.parse(rec.timestamp)
if (Number.isFinite(t)) return t
}
if (typeof rec.timestamp === 'number' && Number.isFinite(rec.timestamp)) return rec.timestamp
const msg = rec.message
if (msg && typeof msg === 'object') {
const m = msg as Record<string, unknown>
if (typeof m.timestamp === 'string') {
const t = Date.parse(m.timestamp)
if (Number.isFinite(t)) return t
}
if (typeof m.timestamp === 'number' && Number.isFinite(m.timestamp)) return m.timestamp
}
return 0
}
async function parseSubagentsFromSessionFile(filePath: string, sessionKey: string): Promise<SubagentInfo[]> {
const subagents: SubagentInfo[] = []
try {
const files = await fs.readdir(agentSessionsDir)
if (files.length === 0) return subagents
// Find most recent file
let latestFile = ''
let latestTime = 0
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 > latestTime) {
latestTime = stat.mtimeMs
latestFile = filePath
}
}
if (!latestFile) return subagents
// Read last 8KB for recent activity
const stat = await fs.stat(latestFile)
const readSize = Math.min(8192, stat.size)
const handle = await fs.open(latestFile, 'r')
const buffer = Buffer.alloc(readSize)
await handle.read(buffer, 0, readSize, Math.max(0, stat.size - readSize))
await handle.close()
const content = buffer.toString('utf-8')
const content = await fs.readFile(filePath, 'utf8')
const lines = content.split('\n').filter(l => l.trim())
// Look for active subtask tool_use entries
const activeSubtasks = new Map<string, string>()
const activeSubtasks = new Map<string, { label: string; at: number }>()
const spawnToolIds = new Set<string>()
for (const line of lines) {
try {
const record = JSON.parse(line)
const eventAt = parseRecordTimestamp(record)
// Legacy format
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 === 'tool_use' && typeof block.input?.description === 'string') {
const desc = block.input.description as string
if (desc.startsWith('Subtask:') || desc.includes('subtask')) {
activeSubtasks.set(block.id, desc)
}
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 })
spawnToolIds.add(block.id)
continue
}
if (typeof block.input?.description === 'string' && isSubtaskDescription(block.input.description)) {
activeSubtasks.set(block.id, { label: block.input.description, at: eventAt })
}
}
}
// Clear completed subtasks
if (record.type === 'user' && record.message?.content) {
const blocks = Array.isArray(record.message.content) ? record.message.content : []
for (const block of blocks) {
if (block.type === 'tool_result' && block.tool_use_id) {
if (block.type === 'tool_result' && block.tool_use_id && !spawnToolIds.has(block.tool_use_id)) {
activeSubtasks.delete(block.tool_use_id)
}
}
}
// New format
if (record.type === 'message' && record.message) {
const msg = record.message
const role = typeof msg.role === 'string' ? msg.role : ''
const blocks = Array.isArray(msg.content) ? msg.content : []
if (role === 'assistant') {
for (const block of blocks) {
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 })
spawnToolIds.add(block.id)
} else if (typeof block.arguments?.description === 'string' && isSubtaskDescription(block.arguments.description)) {
activeSubtasks.set(block.id, { label: block.arguments.description, at: eventAt })
}
} else if (block?.type === 'tool_use' && typeof block.id === 'string' && typeof block.input?.description === 'string') {
if (isSubtaskDescription(block.input.description)) activeSubtasks.set(block.id, { label: block.input.description, at: eventAt })
}
}
} else if (role === 'toolResult') {
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : ''
const toolName = typeof msg.toolName === 'string' ? msg.toolName : ''
if (toolCallId && !isSpawnTool(toolName) && !spawnToolIds.has(toolCallId)) {
activeSubtasks.delete(toolCallId)
}
} else if (role === 'user') {
const text = blocks
.map((b: { type?: string; text?: string }) => (b?.type === 'text' && typeof b.text === 'string') ? b.text : '')
.join('\n')
const completedLabel = extractCompletedSubagentLabel(text)
if (completedLabel) {
for (const [id, state] of activeSubtasks.entries()) {
if (state.label === completedLabel || state.label.includes(completedLabel) || completedLabel.includes(state.label)) {
activeSubtasks.delete(id)
break
}
}
}
}
}
} catch {
// Skip unparseable lines
// Skip bad line
}
}
for (const [toolId, label] of activeSubtasks) {
subagents.push({ toolId, label })
const now = Date.now()
for (const [toolId, state] of activeSubtasks.entries()) {
if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue
const label = state.label
subagents.push({ toolId, label, sessionKey })
}
} catch {
// Ignore parse errors
@@ -90,6 +175,89 @@ async function parseSubagents(agentSessionsDir: string): Promise<SubagentInfo[]>
return subagents
}
/** Parse subagents from all parent sessions (main/direct/group/openai/cron etc.), grouped by session */
async function parseSubagents(agentSessionsDir: string, agentId: string): Promise<SubagentInfo[]> {
const allSubagents: SubagentInfo[] = []
try {
const cutoff = Date.now() - SESSION_LOOKBACK_MS
const sessionFiles: Array<{ sessionKey: string; filePath: string; updatedAt: number }> = []
const knownFilePaths = new Set<string>()
const subagentSessionIds = new Set<string>()
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
if (existsSync(sessionsIndexPath)) {
try {
const sessionsIndexRaw = await fs.readFile(sessionsIndexPath, 'utf8')
const sessionsIndex = JSON.parse(sessionsIndexRaw) as Record<string, { sessionId?: string; updatedAt?: number }>
for (const [sessionKey, meta] of Object.entries(sessionsIndex)) {
if (!meta || typeof meta.sessionId !== 'string' || !meta.sessionId) continue
if (sessionKey.includes(':subagent:')) {
subagentSessionIds.add(meta.sessionId)
continue
}
const filePath = path.join(agentSessionsDir, `${meta.sessionId}.jsonl`)
if (!existsSync(filePath)) continue
let updatedAt = 0
if (typeof meta.updatedAt === 'number' && meta.updatedAt > 0) {
updatedAt = meta.updatedAt
} else {
try {
const stat = await fs.stat(filePath)
updatedAt = stat.mtimeMs
} catch {
updatedAt = 0
}
}
if (updatedAt > 0 && updatedAt < cutoff) continue
sessionFiles.push({ sessionKey, filePath, updatedAt })
knownFilePaths.add(filePath)
}
} catch {
// Ignore index parse errors
}
}
// Fallback: include recent parent session files that are missing in sessions.json mapping.
try {
const orphanCutoff = Date.now() - ORPHAN_FALLBACK_WINDOW_MS
const files = await fs.readdir(agentSessionsDir)
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
if (file.startsWith('probe-')) continue
const filePath = path.join(agentSessionsDir, file)
if (knownFilePaths.has(filePath)) continue
const sessionId = file.slice(0, -'.jsonl'.length)
if (subagentSessionIds.has(sessionId)) continue
const stat = await fs.stat(filePath)
if (stat.mtimeMs < orphanCutoff) continue
if (stat.mtimeMs < cutoff) continue
sessionFiles.push({
sessionKey: `agent:${agentId}:orphan:${sessionId}`,
filePath,
updatedAt: stat.mtimeMs,
})
}
} catch {
// Ignore fallback scan errors
}
sessionFiles.sort((a, b) => b.updatedAt - a.updatedAt)
const candidates = sessionFiles.slice(0, MAX_PARENT_SESSIONS_TO_PARSE)
const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(s.filePath, s.sessionKey)))
const dedupe = new Set<string>()
for (const list of nested) {
for (const sub of list) {
const key = `${sub.sessionKey || ''}::${sub.toolId}`
if (dedupe.has(key)) continue
dedupe.add(key)
allSubagents.push(sub)
}
}
} catch {
// Ignore parse errors
}
return allSubagents
}
export async function GET() {
const openclawDir = path.join(os.homedir(), '.openclaw')
const configPath = path.join(openclawDir, 'openclaw.json')
@@ -138,10 +306,10 @@ export async function GET() {
state = 'idle'
}
// Parse subagents for working agents
// Parse subagents for online agents
let subagents: SubagentInfo[] | undefined
if (state === 'working' && agentSessionsDir && existsSync(agentSessionsDir)) {
subagents = await parseSubagents(agentSessionsDir)
if (state !== 'offline' && agentSessionsDir && existsSync(agentSessionsDir)) {
subagents = await parseSubagents(agentSessionsDir, agent.id)
if (subagents.length === 0) subagents = undefined
}
@@ -160,5 +328,8 @@ export async function GET() {
console.error('Error reading agent activity:', error)
}
return NextResponse.json({ agents })
return NextResponse.json(
{ agents },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0' } },
)
}
+120 -38
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { OfficeState } from '@/lib/pixel-office/engine/officeState'
import { renderFrame } from '@/lib/pixel-office/engine/renderer'
import { buildGatewayUrl } from "@/lib/gateway-url"
@@ -45,6 +45,16 @@ type ReleaseInfo = {
htmlUrl: string
}
type AgentStats = {
sessionCount: number
messageCount: number
totalTokens: number
todayAvgResponseMs: number
weeklyResponseMs: number[]
weeklyTokens: number[]
lastActive: number | null
}
function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: { data: number[]; width?: number; height?: number; color?: string }) {
const hasData = data.some(v => v > 0)
if (!hasData) return null
@@ -162,6 +172,7 @@ const MOBILE_VIEW_NUDGE_Y_PX = -10
const CODE_SNIPPET_LIFETIME_SEC = 5.5
const FLOATING_TICK_INTERVAL_DESKTOP_MS = 48
const FLOATING_TICK_INTERVAL_MOBILE_MS = 32
const AGENT_ACTIVITY_POLL_INTERVAL_MS = 1000
let cachedOfficeState: OfficeState | null = null
let cachedEditorState: EditorState | null = null
@@ -191,7 +202,7 @@ export default function PixelOfficePage() {
const [agents, setAgents] = useState<AgentActivity[]>(cachedAgents)
const [hoveredAgentId, setHoveredAgentId] = useState<number | null>(null)
const mousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const agentStatsRef = useRef<Map<string, { sessionCount: number; messageCount: number; totalTokens: number; todayAvgResponseMs: number; weeklyResponseMs: number[]; weeklyTokens: number[]; lastActive: number | null }>>(new Map())
const agentStatsRef = useRef<Map<string, AgentStats>>(new Map())
const contributionsRef = useRef<ContributionData | null>(null)
const photographRef = useRef<HTMLImageElement | null>(null)
const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 })
@@ -556,7 +567,7 @@ export default function PixelOfficePage() {
}
const fetchAgents = async () => {
try {
const res = await fetch('/api/agent-activity')
const res = await fetch('/api/agent-activity', { cache: 'no-store' })
const data = await res.json()
const newAgents: AgentActivity[] = data.agents || []
setAgents(newAgents)
@@ -596,7 +607,7 @@ export default function PixelOfficePage() {
}
}
fetchAgents()
const interval = setInterval(fetchAgents, 10000)
const interval = setInterval(fetchAgents, AGENT_ACTIVITY_POLL_INTERVAL_MS)
return () => clearInterval(interval)
}, [])
@@ -1230,10 +1241,27 @@ export default function PixelOfficePage() {
for (const [aid, cid] of map.entries()) {
if (cid === hoveredAgentId) { agentId = aid; break }
}
if (!agentId) return null
const agent = agents.find(a => a.agentId === agentId)
const stats = agentStatsRef.current.get(agentId)
return { agent, stats }
if (agentId) {
const agent = agents.find(a => a.agentId === agentId)
const stats = agentStatsRef.current.get(agentId)
return { agent, stats, isSubagent: false as const, parentAgentId: null as string | null }
}
const hoveredCharacter = officeRef.current?.characters.get(hoveredAgentId)
if (!hoveredCharacter?.isSubagent || hoveredCharacter.parentAgentId == null) return null
let parentAgentId: string | null = null
for (const [aid, cid] of map.entries()) {
if (cid === hoveredCharacter.parentAgentId) { parentAgentId = aid; break }
}
if (!parentAgentId) return null
const parentAgent = agents.find(a => a.agentId === parentAgentId)
if (!parentAgent) return null
return {
agent: parentAgent,
stats: agentStatsRef.current.get(parentAgentId),
isSubagent: true as const,
parentAgentId,
}
}, [hoveredAgentId, agents])
const hoveredInfo = getHoveredAgentInfo()
@@ -1248,28 +1276,76 @@ export default function PixelOfficePage() {
isMobileViewport
? `w-full ${maxHeight} overflow-y-auto rounded-t-2xl border-x border-t border-[var(--border)] bg-[var(--card)] shadow-2xl p-4 pb-6`
: `${desktopWidth} ${maxHeight} overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4`
const displayAgents = useMemo<AgentActivity[]>(() => {
const expanded: AgentActivity[] = []
for (const agent of agents) {
expanded.push(agent)
if (!agent.subagents?.length) continue
for (const sub of agent.subagents) {
const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId
expanded.push({
agentId: `subagent:${agent.agentId}:${subKey}`,
name: `临时工 ${agent.agentId}`,
emoji: agent.emoji,
state: 'working',
lastActive: agent.lastActive,
})
}
}
return expanded
}, [agents])
const mobileAgentPages: AgentActivity[][] = []
for (let i = 0; i < agents.length; i += 9) {
mobileAgentPages.push(agents.slice(i, i + 9))
for (let i = 0; i < displayAgents.length; i += 9) {
mobileAgentPages.push(displayAgents.slice(i, i + 9))
}
const renderAgentChip = (agent: AgentActivity, mobileGrid = false) => {
const isTempWorker = agent.agentId.startsWith('subagent:')
const parentAgentIdFromKey = isTempWorker ? (agent.agentId.split(':')[1] || '') : ''
const tempWorkerOwner = isTempWorker ? (agent.name.replace(/^临时工\s*/, '') || parentAgentIdFromKey) : ''
const chipTooltip = isTempWorker
? `${tempWorkerOwner} agent创建的subagent`
: `agent id${agent.agentId}`
const chipToneClass = isTempWorker
? 'bg-red-900/45 border-red-700/80 text-red-100 animate-pulse'
: (
agent.state === 'working' ? `pixel-agent-chip-working${isMobileViewport ? '' : ' animate-pulse'}` :
agent.state === 'idle' ? `pixel-agent-chip-idle${isMobileViewport ? '' : ' animate-pulse'}` :
'pixel-agent-chip-neutral'
)
return (
<div key={agent.agentId} className="group relative overflow-visible">
<div className={`pixel-agent-chip inline-flex h-8 items-center overflow-hidden rounded-lg border transition-colors ${
mobileGrid ? 'w-full min-w-0 gap-1.5 px-2 py-1.5' : 'shrink-0 gap-2 px-3 py-1.5'
} ${chipToneClass}`}
title={chipTooltip}
aria-label={chipTooltip}
{...(agent.state === 'working'
? { style: { animationDuration: isTempWorker ? '0.9s' : '1.3s' } }
: {})}
>
<span className={mobileGrid ? 'shrink-0 text-sm' : ''}>{agent.emoji}</span>
{isTempWorker ? (
<span className={`min-w-0 flex flex-col justify-center ${mobileGrid ? 'max-w-[4.6rem]' : 'max-w-[5.8rem]'} leading-none`}>
<span className={`${mobileGrid ? 'text-[10px]' : 'text-[12px]'} truncate`}></span>
<span className={`${mobileGrid ? 'text-[10px]' : 'text-[12px]'} truncate`}>{tempWorkerOwner}</span>
</span>
) : (
<span className={mobileGrid ? 'min-w-0 text-xs truncate' : 'text-sm'}>{agent.name}</span>
)}
{agent.state === 'working' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'} ${isTempWorker ? 'text-red-100' : 'text-green-200'}`}>{t('pixelOffice.state.working')}</span>}
{agent.state === 'idle' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.idle')}</span>}
{agent.state === 'offline' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.offline')}</span>}
{agent.state === 'waiting' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.waiting')}</span>}
</div>
{!isMobileViewport && (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[calc(100%+6px)] whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--card)]/95 px-2 py-1 text-[11px] text-[var(--text)] opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100">
{chipTooltip}
</div>
)}
</div>
)
}
const renderAgentChip = (agent: AgentActivity, mobileGrid = false) => (
<div key={agent.agentId} className={`pixel-agent-chip inline-flex items-center rounded-lg border transition-colors ${
mobileGrid ? 'w-full min-w-0 gap-1.5 px-2 py-1.5' : 'shrink-0 gap-2 px-3 py-1.5'
} ${
agent.state === 'working' ? `pixel-agent-chip-working${isMobileViewport ? '' : ' animate-pulse'}` :
agent.state === 'idle' ? `pixel-agent-chip-idle${isMobileViewport ? '' : ' animate-pulse'}` :
'pixel-agent-chip-neutral'
}`}
{...(agent.state === 'working' && !isMobileViewport ? { style: { animationDuration: '1.3s' } } : {})}
>
<span className={mobileGrid ? 'shrink-0 text-sm' : ''}>{agent.emoji}</span>
<span className={mobileGrid ? 'min-w-0 text-xs truncate' : 'text-sm'}>{agent.name}</span>
{agent.state === 'working' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.working')}</span>}
{agent.state === 'idle' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.idle')}</span>}
{agent.state === 'offline' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.offline')}</span>}
{agent.state === 'waiting' && <span className={`pixel-agent-chip-state uppercase tracking-wider ${mobileGrid ? 'text-[9px] truncate' : 'text-[10px]'}`}>{t('pixelOffice.state.waiting')}</span>}
</div>
)
return (
<div className="relative flex flex-col overflow-hidden h-[calc(100dvh-3.5rem)] md:h-full">
@@ -1329,7 +1405,7 @@ export default function PixelOfficePage() {
</div>
</div>
<div className="md:hidden overflow-x-auto pb-1">
{agents.length === 0 ? (
{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">
@@ -1345,8 +1421,8 @@ export default function PixelOfficePage() {
)}
</div>
<div className="hidden md:flex gap-2 flex-1 flex-wrap">
{agents.map((agent) => renderAgentChip(agent))}
{agents.length === 0 && (
{displayAgents.map((agent) => renderAgentChip(agent))}
{displayAgents.length === 0 && (
<div className="text-[var(--text-muted)] text-sm">{t('common.noData')}</div>
)}
</div>
@@ -1401,14 +1477,20 @@ export default function PixelOfficePage() {
style={{ left: Math.min(mousePosRef.current.x + 12, (containerRef.current?.clientWidth || 300) - 180), top: mousePosRef.current.y + 12 }}>
<div className="flex items-center gap-1.5 mb-1.5">
<span>{hoveredInfo.agent.emoji}</span>
<span className="font-semibold text-[var(--text)]">{hoveredInfo.agent.name}</span>
</div>
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4"><span>{t('agent.sessionCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.sessionCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.messageCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.messageCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.tokenUsage')}</span><span className="text-[var(--text)]">{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.todayAvgResponse')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}</span></div>
<span className="font-semibold text-[var(--text)]">{hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent.name}</span>
</div>
{hoveredInfo.isSubagent ? (
<div className="text-[var(--text-muted)]">
{(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent
</div>
) : (
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4"><span>{t('agent.sessionCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.sessionCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.messageCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.messageCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.tokenUsage')}</span><span className="text-[var(--text)]">{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.todayAvgResponse')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}</span></div>
</div>
)}
</div>
)}
+18 -7
View File
@@ -3,6 +3,7 @@ import { OfficeState } from './engine/officeState'
export interface SubagentInfo {
toolId: string
label: string
sessionKey?: string
}
export interface AgentActivity {
@@ -16,8 +17,9 @@ export interface AgentActivity {
subagents?: SubagentInfo[]
}
/** Track which subagent toolIds were active last sync, per parent agent */
/** Track which subagent keys were active last sync, per parent agent */
const prevSubagentKeys = new Map<string, Set<string>>()
const TEMP_WORKER_LABEL = '临时工'
/** Track previous agent states to detect offline→working transitions */
const prevAgentStates = new Map<string, string>()
@@ -88,11 +90,20 @@ export function syncAgentsToOffice(
const currentSubKeys = new Set<string>()
if (activity.subagents) {
for (const sub of activity.subagents) {
currentSubKeys.add(sub.toolId)
const existingSubId = office.getSubagentId(charId, sub.toolId)
const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId
currentSubKeys.add(subKey)
const existingSubId = office.getSubagentId(charId, subKey)
if (existingSubId === null) {
const subId = office.addSubagent(charId, sub.toolId)
const subId = office.addSubagent(charId, subKey)
office.setAgentActive(subId, true)
const subCh = office.characters.get(subId)
if (subCh) subCh.label = TEMP_WORKER_LABEL
} else {
const subCh = office.characters.get(existingSubId)
if (subCh) {
subCh.label = TEMP_WORKER_LABEL
office.setAgentActive(existingSubId, true)
}
}
}
}
@@ -100,9 +111,9 @@ export function syncAgentsToOffice(
// Remove subagents that are no longer active
const prevKeys = prevSubagentKeys.get(activity.agentId)
if (prevKeys) {
for (const toolId of prevKeys) {
if (!currentSubKeys.has(toolId)) {
office.removeSubagent(charId, toolId)
for (const subKey of prevKeys) {
if (!currentSubKeys.has(subKey)) {
office.removeSubagent(charId, subKey)
}
}
}
+3 -1
View File
@@ -78,6 +78,7 @@ export function createCharacter(
hueShift,
frame: 0,
frameTimer: 0,
moveSpeedMultiplier: 1,
wanderTimer: 0,
wanderCount: 0,
wanderLimit: randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX),
@@ -310,7 +311,8 @@ export function updateCharacter(
const nextTile = ch.path[0]
ch.dir = directionBetween(ch.tileCol, ch.tileRow, nextTile.col, nextTile.row)
ch.moveProgress += (WALK_SPEED_PX_PER_SEC / TILE_SIZE) * dt
const speedMultiplier = ch.moveSpeedMultiplier > 0 ? ch.moveSpeedMultiplier : 1
ch.moveProgress += ((WALK_SPEED_PX_PER_SEC * speedMultiplier) / TILE_SIZE) * dt
const fromCenter = tileCenter(ch.tileCol, ch.tileRow)
const toCenter = tileCenter(nextTile.col, nextTile.row)
+75 -8
View File
@@ -88,6 +88,15 @@ const PHOTO_COMMENTS = [
'不愧是中国布列松', '这是决定性瞬间!!!', '这个复杂构图绝了!',
]
const TEMP_WORKER_LABEL = '临时工'
const SUBAGENT_PRIORITY_SEAT_IDS = [
'stool-r1', 'stool-r2', 'stool-r3', 'stool-r4',
'stool-r5', 'stool-r6', 'stool-r7', 'stool-r8',
] as const
const SUBAGENT_SPAWN_CENTER_COL = 10
const SUBAGENT_SPAWN_CENTER_ROW = 14
const SUBAGENT_RUN_SPEED_MULTIPLIER = 2.8
export class OfficeState {
layout: OfficeLayout
tileMap: TileTypeVal[][]
@@ -246,6 +255,25 @@ export class OfficeState {
return null
}
private getSubagentSpawnCandidates(): Array<{ col: number; row: number }> {
if (this.walkableTiles.length === 0) return [{ col: 1, row: 1 }]
const preferred = this.walkableTiles.filter((t) => t.row >= 11)
const candidates = preferred.length > 0 ? preferred : this.walkableTiles
const occupied = new Set<string>()
for (const ch of this.characters.values()) {
occupied.add(`${ch.tileCol},${ch.tileRow}`)
}
const free = candidates.filter((t) => !occupied.has(`${t.col},${t.row}`))
const source = free.length > 0 ? free : candidates
return source
.slice()
.sort((a, b) => {
const da = Math.abs(a.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(a.row - SUBAGENT_SPAWN_CENTER_ROW)
const db = Math.abs(b.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(b.row - SUBAGENT_SPAWN_CENTER_ROW)
return da - db
})
}
/**
* Pick a diverse palette for a new agent based on currently active agents.
* First 6 agents each get a unique skin (random order). Beyond 6, skins
@@ -667,13 +695,22 @@ export class OfficeState {
Math.abs(c - parentCol) + Math.abs(r - parentRow)
let bestSeatId: string | null = null
let bestDist = Infinity
for (const [uid, seat] of this.seats) {
if (!seat.assigned) {
const d = dist(seat.seatCol, seat.seatRow)
if (d < bestDist) {
bestDist = d
bestSeatId = uid
for (const seatId of SUBAGENT_PRIORITY_SEAT_IDS) {
const seat = this.seats.get(seatId)
if (seat && !seat.assigned) {
bestSeatId = seatId
break
}
}
if (!bestSeatId) {
let bestDist = Infinity
for (const [uid, seat] of this.seats) {
if (!seat.assigned) {
const d = dist(seat.seatCol, seat.seatRow)
if (d < bestDist) {
bestDist = d
bestSeatId = uid
}
}
}
}
@@ -682,7 +719,35 @@ export class OfficeState {
if (bestSeatId) {
const seat = this.seats.get(bestSeatId)!
seat.assigned = true
ch = createCharacter(id, palette, bestSeatId, seat, hueShift)
ch = createCharacter(id, palette, bestSeatId, null, hueShift)
ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER
const targetCol = Math.round(seat.seatCol)
const targetRow = Math.round(seat.seatRow)
const spawnCandidates = this.getSubagentSpawnCandidates()
let selectedSpawn: { col: number; row: number } | null = null
let selectedPath: Array<{ col: number; row: number }> = []
for (const candidate of spawnCandidates) {
const path = this.withOwnSeatUnblocked(ch, () =>
findPath(candidate.col, candidate.row, targetCol, targetRow, this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
selectedSpawn = candidate
selectedPath = path
break
}
}
if (selectedSpawn && selectedPath.length > 0) {
ch.tileCol = selectedSpawn.col
ch.tileRow = selectedSpawn.row
ch.x = selectedSpawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = selectedSpawn.row * TILE_SIZE + TILE_SIZE / 2
ch.path = selectedPath
ch.state = CharacterState.WALK
ch.moveProgress = 0
} else {
// Rare fallback: if no candidate has a path, keep old behavior and place at seat.
ch = createCharacter(id, palette, bestSeatId, seat, hueShift)
}
} else {
// No seats — spawn at closest walkable tile to parent
let spawn = { col: 1, row: 1 }
@@ -703,9 +768,11 @@ export class OfficeState {
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER
}
ch.isSubagent = true
ch.parentAgentId = parentAgentId
ch.label = TEMP_WORKER_LABEL
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
+54 -1
View File
@@ -231,6 +231,11 @@ export function renderScene(
photograph?: HTMLImageElement,
): void {
const drawables: ZDrawable[] = []
const laptopSizeScale = 0.7
const laptopXTiltRad = (50 * Math.PI) / 180
const laptopUpwardSpinRad = -Math.PI / 12
const laptopTiltScaleY = Math.max(0.22, Math.abs(Math.cos(laptopXTiltRad)))
const laptopTiltSkewX = -Math.sin(laptopXTiltRad) * 0.35
// Wall decorations as z-sorted drawables (zY just above row 0 walls so they render on top of walls but below characters)
const wallDecoZY = TILE_SIZE + 0.5
@@ -301,6 +306,52 @@ export function renderScene(
for (const ch of characters) {
const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET
// Subagent temporary laptop: place it in front of the character using
// live world coordinates, so it stays aligned with seated offsets.
if (ch.isSubagent && ch.state === CharacterState.TYPE && ch.seatId) {
let dx = 0
let dy = 0
if (ch.dir === Direction.LEFT) dx = -1
else if (ch.dir === Direction.RIGHT) dx = 1
else if (ch.dir === Direction.UP) dy = -1
else dy = 1
const forwardOffsetPx = TILE_SIZE * 0.62
const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0
const laptopWorldX = ch.x + dx * forwardOffsetPx
const laptopWorldY = ch.y + sittingOffset + dy * forwardOffsetPx - 5 - TILE_SIZE / 8
const laptopX = offsetX + laptopWorldX * zoom
const laptopY = offsetY + laptopWorldY * zoom
const laptopFacing =
ch.dir === Direction.LEFT ? Direction.RIGHT :
ch.dir === Direction.RIGHT ? Direction.LEFT :
ch.dir === Direction.UP ? Direction.DOWN :
Direction.UP
const laptopRotation =
laptopFacing === Direction.DOWN ? 0 :
laptopFacing === Direction.LEFT ? 90 :
laptopFacing === Direction.UP ? 180 : 270
const laptopZY = laptopWorldY + TILE_SIZE * 0.45
drawables.push({
zY: laptopZY,
draw: (c) => {
const emojiSize = TILE_SIZE * zoom * laptopSizeScale
c.save()
c.translate(Math.round(laptopX), Math.round(laptopY))
c.rotate((laptopRotation * Math.PI) / 180)
// Composite transform: X-axis tilt + extra upward spin around laptop center.
c.rotate(laptopUpwardSpinRad)
c.transform(1, 0, laptopTiltSkewX, laptopTiltScaleY, 0, 0)
c.font = `${emojiSize}px serif`
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillText('💻', 0, 0)
c.restore()
},
})
}
if (ch.isLobster) {
const lobsterX = Math.round(offsetX + ch.x * zoom)
const lobsterY = Math.round(offsetY + ch.y * zoom + 2 * zoom)
@@ -409,7 +460,9 @@ export function renderScene(
const isWorking = ch.isActive && ch.state === CharacterState.TYPE
// Blink effect for working state: use time-based alpha
const labelAlpha = isWorking ? 0.7 + 0.3 * Math.sin(Date.now() / 300) : 1.0
const labelColor = isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700'
const labelColor = ch.isSubagent
? (isWorking ? `rgba(220,38,38,${labelAlpha})` : '#991B1B')
: (isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700')
drawables.push({
zY: charZY + 0.1,
draw: (c) => {
+1
View File
@@ -183,6 +183,7 @@ export interface Character {
hueShift: number
frame: number
frameTimer: number
moveSpeedMultiplier: number
wanderTimer: number
wanderCount: number
wanderLimit: number