mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat(pixel-office): sync and render subagents with mobile/desktop UI updates
This commit is contained in:
+215
-44
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -183,6 +183,7 @@ export interface Character {
|
||||
hueShift: number
|
||||
frame: number
|
||||
frameTimer: number
|
||||
moveSpeedMultiplier: number
|
||||
wanderTimer: number
|
||||
wanderCount: number
|
||||
wanderLimit: number
|
||||
|
||||
Reference in New Issue
Block a user