feat(agent): 添加对持久化子智能体的支持并增强委派管理

添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。
新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。

同时增强了委派管理器的功能:
- 添加了对本地委派、插件委派和本地回退的开关控制
- 实现了持久化子智能体任务的自动检测和本地执行保护
- 增加了对不同委派类型的权限验证机制

修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
This commit is contained in:
2026-03-27 10:15:35 +08:00
parent bad1e16ab4
commit 29dfd14aa6
133 changed files with 11656 additions and 220 deletions

View File

@ -1,11 +1,30 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
import {
AlertCircle,
Bot,
ChevronDown,
Loader2,
Pencil,
Plus,
RefreshCw,
Tags,
Trash2,
} from 'lucide-react';
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
import {
addAgent,
createSubagent,
deleteAgent,
deleteSubagent,
listAgents,
listSubagents,
refreshAgents,
updateSubagent,
} from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { UiAgentDescriptor } from '@/types';
import type { UiAgentDescriptor, UiSubagentDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -13,9 +32,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
const EMPTY_FORM = {
const EMPTY_AGENT_FORM = {
id: '',
name: '',
description: '',
@ -30,17 +51,69 @@ const EMPTY_FORM = {
aliases: '',
};
const EMPTY_SUBAGENT_FORM = {
id: '',
name: '',
description: '',
system_prompt: '',
model: '',
delegation_mode: 'remote_a2a_only',
enabled: true,
allow_mcp: true,
tags: '',
aliases: '',
metadata_json: '{}',
mcp_servers_json: '{}',
};
function formatJson(value: Record<string, unknown>): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(raw: string, label: string): Record<string, unknown> {
const probe = raw.trim();
if (!probe) {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(probe);
} catch {
throw new Error(`${label} 需要是合法 JSON`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 需要是 JSON 对象`);
}
return parsed as Record<string, unknown>;
}
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> {
const parsed = parseJsonObject(raw, label);
for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`);
}
}
return parsed as Record<string, Record<string, unknown>>;
}
export default function AgentsPage() {
const cachedAgents = useChatStore((s) => s.agentRegistry);
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
const [subagents, setSubagents] = useState<UiSubagentDescriptor[]>([]);
const [loading, setLoading] = useState(cachedAgents.length === 0);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const [subagentDialogOpen, setSubagentDialogOpen] = useState(false);
const [agentSubmitting, setAgentSubmitting] = useState(false);
const [subagentSubmitting, setSubagentSubmitting] = useState(false);
const [agentAdvancedOpen, setAgentAdvancedOpen] = useState(false);
const [subagentAdvancedOpen, setSubagentAdvancedOpen] = useState(false);
const [agentForm, setAgentForm] = useState(EMPTY_AGENT_FORM);
const [subagentForm, setSubagentForm] = useState(EMPTY_SUBAGENT_FORM);
const [editingSubagentId, setEditingSubagentId] = useState<string | null>(null);
const load = useCallback(async (background = false) => {
if (background) {
@ -50,9 +123,14 @@ export default function AgentsPage() {
}
setError(null);
try {
const data = await listAgents();
const nextAgents = Array.isArray(data) ? data : [];
const [agentData, subagentData] = await Promise.all([
listAgents(),
listSubagents(),
]);
const nextAgents = Array.isArray(agentData) ? agentData : [];
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
setAgents(nextAgents);
setSubagents(nextSubagents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '加载智能体失败');
@ -73,9 +151,14 @@ export default function AgentsPage() {
setError(null);
setRefreshing(true);
try {
const data = await refreshAgents();
const nextAgents = data.agents || [];
const [agentData, subagentData] = await Promise.all([
refreshAgents(),
listSubagents(),
]);
const nextAgents = agentData.agents || [];
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
setAgents(nextAgents);
setSubagents(nextSubagents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '刷新智能体失败');
@ -84,59 +167,133 @@ export default function AgentsPage() {
}
};
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
const handleAgentDialogOpenChange = (open: boolean) => {
setAgentDialogOpen(open);
if (!open) {
setAdvancedOpen(false);
setForm(EMPTY_FORM);
setAgentAdvancedOpen(false);
setAgentForm(EMPTY_AGENT_FORM);
}
};
const handleCreate = async (e: React.FormEvent) => {
const handleSubagentDialogOpenChange = (open: boolean) => {
setSubagentDialogOpen(open);
if (!open) {
setSubagentAdvancedOpen(false);
setEditingSubagentId(null);
setSubagentForm(EMPTY_SUBAGENT_FORM);
}
};
const handleCreateAgent = async (e: React.FormEvent) => {
e.preventDefault();
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim());
if (!hasAddress) {
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
return;
}
setSubmitting(true);
setAgentSubmitting(true);
setError(null);
try {
await addAgent({
id: form.id || undefined,
name: form.name || undefined,
description: form.description || undefined,
id: agentForm.id || undefined,
name: agentForm.name || undefined,
description: agentForm.description || undefined,
protocol: 'a2a',
base_url: form.base_url || undefined,
endpoint: form.endpoint || undefined,
card_url: form.card_url || undefined,
auth_env: form.auth_env || undefined,
auth_mode: form.auth_mode || 'none',
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
auth_scopes: form.auth_mode === 'none'
base_url: agentForm.base_url || undefined,
endpoint: agentForm.endpoint || undefined,
card_url: agentForm.card_url || undefined,
auth_env: agentForm.auth_env || undefined,
auth_mode: agentForm.auth_mode || 'none',
auth_audience: agentForm.auth_mode === 'none' ? undefined : agentForm.auth_audience || undefined,
auth_scopes: agentForm.auth_mode === 'none'
? []
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
: agentForm.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
tags: agentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: agentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
});
handleDialogOpenChange(false);
await load();
handleAgentDialogOpenChange(false);
await load(true);
} catch (err: any) {
setError(err.message || '新增智能体失败');
} finally {
setSubmitting(false);
setAgentSubmitting(false);
}
};
const handleDelete = async (agentId: string) => {
const handleDeleteAgent = async (agentId: string) => {
try {
await deleteAgent(agentId);
await load();
await load(true);
} catch (err: any) {
setError(err.message || '删除智能体失败');
}
};
const handleEditSubagent = (subagent: UiSubagentDescriptor) => {
setEditingSubagentId(subagent.id);
setSubagentForm({
id: subagent.id,
name: subagent.name,
description: subagent.description,
system_prompt: subagent.system_prompt || '',
model: subagent.model || '',
delegation_mode: subagent.delegation_mode || 'remote_a2a_only',
enabled: subagent.enabled,
allow_mcp: subagent.allow_mcp,
tags: (subagent.tags || []).join(', '),
aliases: (subagent.aliases || []).join(', '),
metadata_json: formatJson(subagent.metadata || {}),
mcp_servers_json: formatJson(subagent.mcp_servers || {}),
});
setSubagentDialogOpen(true);
};
const handleSaveSubagent = async (e: React.FormEvent) => {
e.preventDefault();
if (!subagentForm.id.trim()) {
setError('Sub-agent ID 不能为空');
return;
}
setSubagentSubmitting(true);
setError(null);
try {
const payload = {
id: subagentForm.id.trim(),
name: subagentForm.name.trim() || subagentForm.id.trim(),
description: subagentForm.description.trim() || subagentForm.name.trim() || subagentForm.id.trim(),
system_prompt: subagentForm.system_prompt,
model: subagentForm.model.trim() || undefined,
enabled: subagentForm.enabled,
delegation_mode: subagentForm.delegation_mode,
allow_mcp: subagentForm.allow_mcp,
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'),
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'),
};
if (editingSubagentId) {
await updateSubagent(editingSubagentId, payload);
} else {
await createSubagent(payload);
}
handleSubagentDialogOpenChange(false);
await load(true);
} catch (err: any) {
setError(err.message || '保存 Sub-Agent 失败');
} finally {
setSubagentSubmitting(false);
}
};
const handleDeleteManagedSubagent = async (subagentId: string) => {
try {
await deleteSubagent(subagentId);
await load(true);
} catch (err: any) {
setError(err.message || '删除 Sub-Agent 失败');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@ -154,17 +311,17 @@ export default function AgentsPage() {
</h1>
<p className="text-sm text-muted-foreground mt-1">
A2A Sub-Agent
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Button size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
@ -173,53 +330,51 @@ export default function AgentsPage() {
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleCreate}>
<form className="space-y-4" onSubmit={handleCreateAgent}>
<div className="space-y-2">
<Label htmlFor="base_url">A2A </Label>
<Input
id="base_url"
value={form.base_url}
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
value={agentForm.base_url}
onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))}
placeholder="https://agent.example.com 或 agent.example.com:19090"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
<code className="mx-1">/.well-known/agent-card</code>
<code className="mx-1">/.well-known/agent-card.json</code>
<code className="mx-1">/.well-known/agent.json</code>
ID
<code className="mx-1">/.well-known</code>
card
</p>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" />
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint"></Label>
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="card_url"></Label>
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
<Input id="card_url" value={agentForm.card_url} onChange={(e) => setAgentForm((s) => ({ ...s, card_url: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
@ -227,8 +382,8 @@ export default function AgentsPage() {
<Label htmlFor="auth_mode"></Label>
<select
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
value={agentForm.auth_mode}
onChange={(e) => setAgentForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
@ -237,46 +392,211 @@ export default function AgentsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="auth_audience">Audience</Label>
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
<Input id="auth_audience" value={agentForm.auth_audience} onChange={(e) => setAgentForm((s) => ({ ...s, auth_audience: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="auth_scopes">Scopes</Label>
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
<Input id="auth_scopes" value={agentForm.auth_scopes} onChange={(e) => setAgentForm((s) => ({ ...s, auth_scopes: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="auth_env"></Label>
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如MY_AGENT_TOKEN" />
<Input id="auth_env" value={agentForm.auth_env} onChange={(e) => setAgentForm((s) => ({ ...s, auth_env: e.target.value }))} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tags"></Label>
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
<Input id="tags" value={agentForm.tags} onChange={(e) => setAgentForm((s) => ({ ...s, tags: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="aliases"></Label>
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如reviewer, audit-agent" />
<Input id="aliases" value={agentForm.aliases} onChange={(e) => setAgentForm((s) => ({ ...s, aliases: e.target.value }))} />
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
card
<code className="mx-1">.well-known</code>
Sub-Agent Sub-Agent registry
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
<Button type="submit" disabled={agentSubmitting}>
{agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={subagentDialogOpen} onOpenChange={handleSubagentDialogOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Sub-Agent
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{editingSubagentId ? '编辑 Sub-Agent' : '新增 Persistent Sub-Agent'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSaveSubagent}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subagent_id">ID</Label>
<Input
id="subagent_id"
value={subagentForm.id}
disabled={Boolean(editingSubagentId)}
onChange={(e) => setSubagentForm((s) => ({ ...s, id: e.target.value }))}
placeholder="research-agent"
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_name"></Label>
<Input
id="subagent_name"
value={subagentForm.name}
onChange={(e) => setSubagentForm((s) => ({ ...s, name: e.target.value }))}
placeholder="Research Agent"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_description"></Label>
<Textarea
id="subagent_description"
rows={3}
value={subagentForm.description}
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
placeholder="用于研究和资料整理的本地持久化子智能体"
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_prompt">System Prompt</Label>
<Textarea
id="subagent_prompt"
rows={6}
value={subagentForm.system_prompt}
onChange={(e) => setSubagentForm((s) => ({ ...s, system_prompt: e.target.value }))}
placeholder="Focus on research tasks, be concise, and cite concrete findings."
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subagent_model"></Label>
<Input
id="subagent_model"
value={subagentForm.model}
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
placeholder="留空则继承主 Agent 默认模型"
/>
</div>
<div className="space-y-2">
<Label htmlFor="delegation_mode"></Label>
<select
id="delegation_mode"
value={subagentForm.delegation_mode}
onChange={(e) => setSubagentForm((s) => ({ ...s, delegation_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="remote_a2a_only">remote_a2a_only</option>
<option value="full">full</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div>
<Label htmlFor="subagent_enabled"></Label>
<p className="text-xs text-muted-foreground mt-1"> workspace </p>
</div>
<Switch
id="subagent_enabled"
checked={subagentForm.enabled}
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, enabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div>
<Label htmlFor="subagent_allow_mcp"> MCP</Label>
<p className="text-xs text-muted-foreground mt-1"> MCP </p>
</div>
<Switch
id="subagent_allow_mcp"
checked={subagentForm.allow_mcp}
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, allow_mcp: checked }))}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subagent_tags"></Label>
<Input
id="subagent_tags"
value={subagentForm.tags}
onChange={(e) => setSubagentForm((s) => ({ ...s, tags: e.target.value }))}
placeholder="research, notes"
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_aliases"></Label>
<Input
id="subagent_aliases"
value={subagentForm.aliases}
onChange={(e) => setSubagentForm((s) => ({ ...s, aliases: e.target.value }))}
placeholder="researcher, local-research"
/>
</div>
</div>
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
JSON Metadata / MCP
<ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="subagent_metadata_json">Metadata JSON</Label>
<Textarea
id="subagent_metadata_json"
rows={6}
value={subagentForm.metadata_json}
onChange={(e) => setSubagentForm((s) => ({ ...s, metadata_json: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_mcp_json">MCP Servers JSON</Label>
<Textarea
id="subagent_mcp_json"
rows={8}
value={subagentForm.mcp_servers_json}
onChange={(e) => setSubagentForm((s) => ({ ...s, mcp_servers_json: e.target.value }))}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
workspace
<code className="mx-1">AGENTS.json</code>
<code className="mx-1">AGENTS.md</code>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
</Button>
<Button type="submit" disabled={subagentSubmitting}>
{subagentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : editingSubagentId ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{editingSubagentId ? '更新' : '创建'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
@ -291,73 +611,178 @@ export default function AgentsPage() {
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{agents.map((agent) => {
const isWorkspace = agent.source === 'workspace';
return (
<Card key={agent.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div>
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
<div className="space-y-2">
{agent.tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
{agent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
{agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
{agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
<Tabs defaultValue="agents" className="space-y-4">
<TabsList>
<TabsTrigger value="agents"></TabsTrigger>
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
</TabsList>
<TabsContent value="agents" className="space-y-4">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{agents.map((agent) => {
const isWorkspace = agent.source === 'workspace';
const isManagedSubagent = Boolean(agent.metadata && agent.metadata.local_subagent);
return (
<Card key={agent.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{isManagedSubagent && <Badge className="bg-amber-600"> Sub-Agent</Badge>}
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div>
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
<div className="space-y-2">
{agent.tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
{agent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
{agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
{agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
</div>
)}
</div>
)}
<div className="flex justify-end">
{isManagedSubagent ? (
<span className="text-xs text-muted-foreground"> Sub-Agent </span>
) : isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</TabsContent>
<TabsContent value="subagents" className="space-y-4">
<Card className="border-border/70 bg-muted/20">
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
Sub-Agent
<code className="mx-1">~/.nanobot/workspace/agents/&lt;id&gt;_agent</code>
workspace`AGENTS.json``AGENTS.md`skills memory
<code className="mx-1">remote_a2a_only</code>
A2A agent
</CardContent>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{subagents.map((subagent) => (
<Card key={subagent.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">{subagent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{subagent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{subagent.description || '—'}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant={subagent.enabled ? 'default' : 'outline'}>
{subagent.enabled ? '启用' : '停用'}
</Badge>
<Badge variant="secondary">{subagent.delegation_mode}</Badge>
{subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>}
{subagent.model && <Badge variant="outline">{subagent.model}</Badge>}
</div>
</div>
)}
<div className="flex justify-end">
{isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
<div><span className="font-medium text-foreground">Workspace</span> {subagent.workspace}</div>
<div><span className="font-medium text-foreground">Base URL</span> {subagent.base_url}</div>
<div><span className="font-medium text-foreground">RPC</span> {subagent.endpoint}</div>
<div><span className="font-medium text-foreground">Card</span> {subagent.card_url}</div>
<div><span className="font-medium text-foreground">MCP Servers</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
</div>
{subagent.system_prompt && (
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs font-medium text-foreground mb-1">System Prompt</div>
<p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-5">
{subagent.system_prompt}
</p>
</div>
)}
{(subagent.tags.length > 0 || subagent.aliases.length > 0) && (
<div className="space-y-2">
{subagent.tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
{subagent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
{subagent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
{subagent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
{subagents.length === 0 && (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Sub-Agent Sub-Agent
</CardContent>
</Card>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -20,6 +20,7 @@ import {
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
@ -132,6 +133,8 @@ export default function CronPage() {
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<TaskManagementTabs />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" />

View File

@ -0,0 +1,532 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React from 'react';
import {
ArrowLeft,
ArrowRight,
Boxes,
FolderOutput,
ListTree,
MessageSquare,
PanelRightOpen,
Siren,
Users,
} from 'lucide-react';
import {
OfficeStatusBadge,
formatOfficeDuration,
formatOfficeTime,
progressPercent,
} from '@/components/office/OfficeShared';
import { OfficePhaserCanvas } from '@/components/office/OfficePhaserCanvas';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
import { useChatStore } from '@/lib/store';
function PixelPanel({
title,
subtitle,
children,
icon: Icon,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="rounded-none border-4 border-[#0e1119] bg-[#141722] p-4 text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
<div className="flex items-center gap-2 font-mono text-sm font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
{Icon ? <Icon className="h-4 w-4" /> : null}
{title}
</div>
{subtitle ? (
<div className="mt-2 text-xs text-slate-400">{subtitle}</div>
) : null}
<div className="mt-4">{children}</div>
</div>
);
}
function BoardPanel({
icon: Icon,
title,
description,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<Card className="rounded-none border-4 border-[#0e1119] bg-[#141722] text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
<CardHeader className="border-b border-[#262a3d] pb-3">
<CardTitle className="flex items-center gap-2 text-base text-[#fef3c7]">
<Icon className="h-4 w-4" />
{title}
</CardTitle>
{description ? <CardDescription className="text-slate-400">{description}</CardDescription> : null}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export default function OfficeDetailPage() {
const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns);
const processEvents = useChatStore((state) => state.processEvents);
const processArtifacts = useChatStore((state) => state.processArtifacts);
const office = React.useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }),
[processArtifacts, processEvents, processRuns, sessions, taskId]
);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const [detailOpen, setDetailOpen] = React.useState(false);
React.useEffect(() => {
setSelectedRunId(office?.rootRunId ?? null);
setDetailOpen(false);
}, [office?.rootRunId]);
const selectedTask = React.useMemo(
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
[office?.tasks, selectedRunId]
);
const selectedEvents = React.useMemo(
() => processEvents
.filter((event) => event.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8),
[processEvents, selectedTask?.runId]
);
const selectedArtifacts = React.useMemo(
() => processArtifacts
.filter((artifact) => artifact.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()),
[processArtifacts, selectedTask?.runId]
);
const openRunDetail = React.useCallback((runId: string) => {
setSelectedRunId(runId);
setDetailOpen(true);
}, []);
if (!office) {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
</Link>
</Button>
<Card className="border-dashed">
<CardContent className="py-16 text-center">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-muted-foreground">
store task Office
</p>
</CardContent>
</Card>
</div>
);
}
const progressValue = progressPercent(office.progress.value, office.progress.max);
return (
<div className="mx-auto max-w-[1720px] space-y-6 p-6">
<TaskManagementTabs />
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="space-y-5">
<div className="mx-auto max-w-[1280px] rounded-none border-4 border-[#0e1119] bg-[#141522] p-4 shadow-[0_0_0_2px_#241d36_inset]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate font-mono text-3xl font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
{office.title}
</h1>
<OfficeStatusBadge status={office.status} className="bg-black/20" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
<span>Lead: {office.rootActorName}</span>
<span>Session: {office.sourceSessionLabel}</span>
<span>Started: {formatOfficeTime(office.createdAt)}</span>
<span>Duration: {formatOfficeDuration(office.durationMs)}</span>
</div>
</div>
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} />
<MetricTile label="参与成员" value={String(office.stats.memberCount)} />
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} />
<MetricTile label="告警数量" value={String(office.alerts.length)} />
</div>
</div>
</div>
<div className="mx-auto max-w-[1280px]">
<OfficePhaserCanvas
office={office}
selectedRunId={selectedTask?.runId ?? null}
onRunSelect={openRunDetail}
showMetaBar={false}
/>
</div>
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
<PixelPanel
title="昨日小记"
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。"
>
<div className="space-y-3 text-sm leading-6 text-slate-300">
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
{selectedTask?.summary || '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。'}
</div>
{office.alerts.slice(0, 2).map((alert) => (
<button
key={alert.id}
type="button"
disabled={!alert.runId}
onClick={() => alert.runId && openRunDetail(alert.runId)}
className="block w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
>
<div className="font-medium text-rose-200">{alert.title}</div>
{alert.description ? <div className="mt-1 text-xs text-slate-400">{alert.description}</div> : null}
</button>
))}
</div>
</PixelPanel>
<PixelPanel
title="任务控制台"
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。"
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">
<span>{office.progress.label}</span>
<span>{progressValue}%</span>
</div>
<div className="h-4 rounded-none border-2 border-[#263144] bg-[#0f1420] p-[2px]">
<div
className="h-full bg-[linear-gradient(90deg,#22d3ee,#fde047,#fb7185)] transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
</div>
{selectedTask ? (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400"></div>
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
<div className="mt-1 text-xs text-slate-400">
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'}
</div>
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button
onClick={() => setDetailOpen(true)}
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
>
<PanelRightOpen className="ml-2 h-4 w-4" />
</Button>
<Button
asChild
variant="outline"
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
>
<Link href="/">
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
{isOfficeTaskTerminal(office.status) ? (
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
</div>
) : null}
</div>
</PixelPanel>
<PixelPanel
title="办公人员名单"
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。"
>
<div className="space-y-2">
{office.members.map((member) => (
<button
key={member.memberId}
type="button"
onClick={() => openRunDetail(member.currentRunId)}
className="flex w-full items-center justify-between gap-3 rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left transition-colors hover:border-[#64748b]"
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-100">{member.actorName}</div>
<div className="truncate text-xs text-slate-400">{member.currentTitle}</div>
</div>
<OfficeStatusBadge status={member.status} />
</button>
))}
</div>
</PixelPanel>
</div>
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
<BoardPanel
icon={ListTree}
title="任务看板"
description="当前 task 下所有 run 的结构化列表。"
>
<div className="space-y-3">
{office.tasks.map((task) => (
<button
key={task.runId}
type="button"
onClick={() => openRunDetail(task.runId)}
className={`w-full rounded-none border-2 px-4 py-3 text-left transition-colors ${
selectedTask?.runId === task.runId
? 'border-[#facc15] bg-[#201922]'
: 'border-[#2d3348] bg-[#0f1420] hover:border-[#64748b]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-100">{task.title}</span>
{task.isRoot ? (
<span className="rounded-none border border-[#4a3c17] bg-[#3b2f12] px-2 py-0.5 font-mono text-[10px] text-[#fef3c7]">
ROOT
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
<span>{task.actorName}</span>
<span>{formatOfficeTime(task.updatedAt)}</span>
<span>{task.artifactCount} </span>
</div>
</div>
<OfficeStatusBadge status={task.status} />
</div>
</button>
))}
</div>
</BoardPanel>
<div className="space-y-5">
<BoardPanel
icon={Boxes}
title="分工关系"
description="主 Agent 到子 Agent 的委派关系。"
>
<div className="space-y-2">
{office.assignments.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
</div>
) : (
office.assignments.map((assignment) => (
<button
key={assignment.ownerRunId}
type="button"
onClick={() => openRunDetail(assignment.ownerRunId)}
className="w-full rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left text-sm transition-colors hover:border-[#64748b]"
>
<div className="font-medium text-slate-100">{assignment.label}</div>
<div className="mt-1 text-slate-400">{assignment.assigneeActorNames.join(' / ')}</div>
</button>
))
)}
</div>
</BoardPanel>
<BoardPanel
icon={Siren}
title="现场告警"
description="优先展示失败、阻塞和较高风险的任务信号。"
>
<div className="space-y-2">
{office.alerts.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
</div>
) : (
office.alerts.map((alert) => (
<button
key={alert.id}
type="button"
disabled={!alert.runId}
onClick={() => alert.runId && openRunDetail(alert.runId)}
className="w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
>
<div className="font-medium text-rose-200">{alert.title}</div>
{alert.description ? <div className="mt-1 text-sm text-slate-400">{alert.description}</div> : null}
</button>
))
)}
</div>
</BoardPanel>
</div>
</div>
</div>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
<SheetHeader className="pr-8">
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle>
<SheetDescription>
{selectedTask
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}`
: '当前没有选中的任务实例。'}
</SheetDescription>
</SheetHeader>
{!selectedTask ? (
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
</div>
) : (
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
<div className="space-y-4 pb-6">
<div className="rounded-xl border border-border/60 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{selectedTask.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{selectedTask.actorName}</div>
</div>
<OfficeStatusBadge status={selectedTask.status} />
</div>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.startedAt)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.updatedAt)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{selectedTask.stageLabel ?? '-'}</span>
</div>
</div>
{selectedTask.summary ? (
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
{selectedTask.summary}
</div>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="space-y-2 p-4">
{selectedArtifacts.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
selectedArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="font-medium">{artifact.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)}
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="space-y-2 p-4">
{selectedEvents.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
selectedEvents.map((event) => (
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div>
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div>
</div>
<div className="mt-2 text-sm text-foreground/90">
{event.text || '结构化更新'}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
</div>
);
}
function MetricTile({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-4 py-4 text-slate-100">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
<div className="mt-2 text-xl font-semibold">{value}</div>
</div>
);
}
function MiniMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-slate-100">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
<div className="mt-2 text-sm font-semibold">{value}</div>
</div>
);
}

View File

@ -0,0 +1,268 @@
'use client';
import Link from 'next/link';
import React from 'react';
import {
Activity,
ArrowRight,
Clock3,
FolderKanban,
Loader2,
Sparkles,
Users,
} from 'lucide-react';
import { OfficeStatusBadge, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { useChatStore } from '@/lib/store';
function TaskCard({
taskId,
title,
sessionLabel,
rootActorName,
status,
updatedAt,
memberCount,
activeRuns,
artifactCount,
errorCount,
currentStageLabel,
progressLabel,
progressValue,
}: {
taskId: string;
title: string;
sessionLabel: string;
rootActorName: string;
status: Parameters<typeof OfficeStatusBadge>[0]['status'];
updatedAt: string;
memberCount: number;
activeRuns: number;
artifactCount: number;
errorCount: number;
currentStageLabel: string | null;
progressLabel: string;
progressValue: number;
}) {
return (
<Card className="border-border/80 transition-colors hover:border-primary/30">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-lg">{title}</CardTitle>
<CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>: {sessionLabel}</span>
<span> Agent: {rootActorName}</span>
<span> {formatOfficeTime(updatedAt)}</span>
</CardDescription>
</div>
<OfficeStatusBadge status={status} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-4">
<Metric icon={Users} label="成员" value={String(memberCount)} />
<Metric icon={Activity} label="活跃" value={String(activeRuns)} />
<Metric icon={FolderKanban} label="产物" value={String(artifactCount)} />
<Metric icon={Sparkles} label="异常" value={String(errorCount)} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-muted-foreground">{progressLabel}</span>
{currentStageLabel ? <span className="truncate font-medium">{currentStageLabel}</span> : null}
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
</div>
<div className="flex justify-end">
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(taskId)}`}>
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
function Metric({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-border/60 bg-muted/30 px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</div>
<div className="mt-2 text-lg font-semibold">{value}</div>
</div>
);
}
export default function OfficeListPage() {
const sessionId = useChatStore((state) => state.sessionId);
const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns);
const processEvents = useChatStore((state) => state.processEvents);
const processArtifacts = useChatStore((state) => state.processArtifacts);
const wsStatus = useChatStore((state) => state.wsStatus);
const tasks = React.useMemo(
() => buildOfficeTaskList({
sessionId,
sessions,
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status));
const recentTasks = tasks.filter((task) => isOfficeTaskTerminal(task.status));
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<TaskManagementTabs />
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Office</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
Agent Agent
</p>
</div>
<Card className="min-w-[280px] border-border/70">
<CardContent className="flex items-center justify-between gap-4 p-4">
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 font-medium">{sessionId}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 font-medium">{wsStatus === 'connected' ? '已连接' : wsStatus}</div>
</div>
</CardContent>
</Card>
</div>
{wsStatus === 'connecting' && tasks.length === 0 ? (
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : null}
{tasks.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Clock3 className="h-10 w-10 text-muted-foreground/50" />
<h2 className="mt-4 text-xl font-semibold"></h2>
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
Agent office
</p>
<Button asChild className="mt-6">
<Link href="/"></Link>
</Button>
</CardContent>
</Card>
) : (
<>
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> Office</h2>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-sm text-muted-foreground">{activeTasks.length} </div>
</div>
{activeTasks.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{activeTasks.map((task) => (
<TaskCard
key={task.taskId}
taskId={task.taskId}
title={task.title}
sessionLabel={task.sessionLabel}
rootActorName={task.rootActorName}
status={task.status}
updatedAt={task.updatedAt}
memberCount={task.memberCount}
activeRuns={task.activeRuns}
artifactCount={task.artifactCount}
errorCount={task.errorCount}
currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)}
/>
))}
</div>
)}
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-sm text-muted-foreground">{recentTasks.length} </div>
</div>
{recentTasks.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{recentTasks.map((task) => (
<TaskCard
key={task.taskId}
taskId={task.taskId}
title={task.title}
sessionLabel={task.sessionLabel}
rootActorName={task.rootActorName}
status={task.status}
updatedAt={task.updatedAt}
memberCount={task.memberCount}
activeRuns={task.activeRuns}
artifactCount={task.artifactCount}
errorCount={task.errorCount}
currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)}
/>
))}
</div>
)}
</section>
</>
)}
</div>
);
}

View File

@ -1,8 +1,10 @@
'use client';
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -19,6 +21,7 @@ import {
uploadFile,
wsManager,
} from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
@ -133,6 +136,19 @@ export default function ChatPage() {
);
}, [commands, input]);
const officeTasks = useMemo(
() => buildOfficeTaskList({
sessionId,
sessions,
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
@ -544,6 +560,37 @@ export default function ChatPage() {
</div>
<div className="flex-1 flex flex-col min-w-0">
{currentOfficeTask ? (
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
</div>
<OfficeStatusBadge status={currentOfficeTask.status} />
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">
{currentOfficeTask.title}
<span className="ml-2"> Agent: {currentOfficeTask.rootActorName}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/office"> Office</Link>
</Button>
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
) : null}
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
@ -554,7 +601,7 @@ export default function ChatPage() {
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
onCancelRun={handleCancelRun}
/>
</div>

View File

@ -8,10 +8,17 @@ import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen,
import { logout } from '@/lib/api';
import { useChatStore } from '@/lib/store';
const NAV_ITEMS = [
type NavItem = {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
matchPrefixes?: string[];
};
const NAV_ITEMS: NavItem[] = [
{ name: '对话', href: '/', icon: MessageSquare },
{ name: '状态', href: '/status', icon: Activity },
{ name: '定时任务', href: '/cron', icon: Clock },
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
{ name: '技能', href: '/skills', icon: Puzzle },
{ name: '插件', href: '/plugins', icon: Blocks },
{ name: '智能体', href: '/agents', icon: Bot },
@ -97,7 +104,7 @@ const Header = () => {
const isActive =
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href);
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link

View File

@ -0,0 +1,533 @@
'use client';
import React from 'react';
import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type RunCardPhase = 'live' | 'exiting' | 'collapsed';
type AgentFeedItem = {
key: string;
created_at: string;
role: 'user' | 'assistant' | 'system' | 'tool';
text: string;
tone?: ProcessRun['status'];
};
const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
const AGENT_ACCENTS = [
{
frame: 'border-sky-500/25 bg-sky-500/[0.05]',
title: 'text-sky-300',
dot: 'bg-sky-400',
result: 'border-sky-500/25 bg-sky-500/[0.08]',
},
{
frame: 'border-emerald-500/25 bg-emerald-500/[0.05]',
title: 'text-emerald-300',
dot: 'bg-emerald-400',
result: 'border-emerald-500/25 bg-emerald-500/[0.08]',
},
{
frame: 'border-amber-500/25 bg-amber-500/[0.05]',
title: 'text-amber-300',
dot: 'bg-amber-400',
result: 'border-amber-500/25 bg-amber-500/[0.08]',
},
{
frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]',
title: 'text-fuchsia-300',
dot: 'bg-fuchsia-400',
result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]',
},
] as const;
function accentFor(index: number) {
return AGENT_ACCENTS[index % AGENT_ACCENTS.length];
}
function statusLabel(status: ProcessRun['status']) {
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'queued') return '排队中';
return '进行中';
}
function statusTone(status: ProcessRun['status']) {
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300';
if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300';
if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
}
function roleLabel(role: AgentFeedItem['role']) {
if (role === 'user') return '主 agent';
if (role === 'tool') return '工具输出';
if (role === 'system') return '状态';
return '子 agent';
}
function feedTone(role: AgentFeedItem['role']) {
if (role === 'user') {
return 'ml-6 border-border/70 bg-muted/60 text-foreground';
}
if (role === 'system') {
return 'mx-4 border-border/60 bg-accent/60 text-foreground/85';
}
if (role === 'tool') {
return 'mr-6 border-border/70 bg-background/80 text-foreground';
}
return 'mr-6 border-border/70 bg-background/80 text-foreground';
}
function artifactPreview(artifact: ProcessArtifact): string {
if (artifact.artifact_type === 'link' && artifact.url) {
return `${artifact.title}\n${artifact.url}`;
}
if ((artifact.artifact_type === 'text' || artifact.artifact_type === 'markdown') && artifact.content) {
return `${artifact.title}\n${artifact.content}`;
}
if (artifact.artifact_type === 'json') {
return `${artifact.title}\n已生成结构化结果`;
}
if (artifact.file_id) {
return `${artifact.title}\n已生成文件输出`;
}
return artifact.title;
}
function delegatedTask(run: ProcessRun): string | null {
const value = run.metadata?.delegated_task;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function buildFeed(
run: ProcessRun,
events: ProcessEvent[],
artifacts: ProcessArtifact[],
): AgentFeedItem[] {
const items: AgentFeedItem[] = [];
let hasLeadBubble = false;
for (const event of events) {
if (!event.text?.trim()) {
continue;
}
if (event.kind === 'run_message') {
const role = event.message_role || 'assistant';
if (role === 'user') {
hasLeadBubble = true;
}
items.push({
key: event.event_id,
created_at: event.created_at,
role,
text: event.text.trim(),
});
continue;
}
if (event.kind === 'run_progress') {
items.push({
key: event.event_id,
created_at: event.created_at,
role: 'assistant',
text: event.text.trim(),
});
continue;
}
if (event.kind === 'run_status' && event.status && event.status !== 'running') {
items.push({
key: event.event_id,
created_at: event.created_at,
role: 'system',
text: event.text.trim(),
tone: event.status,
});
}
}
for (const artifact of artifacts) {
items.push({
key: artifact.artifact_id,
created_at: artifact.created_at,
role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant',
text: artifactPreview(artifact),
});
}
if (!hasLeadBubble) {
const task = delegatedTask(run);
if (task) {
items.push({
key: `${run.run_id}:delegated-task`,
created_at: run.started_at,
role: 'user',
text: task,
});
}
}
return items
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.slice(-8);
}
function runSummary(run: ProcessRun, feed: AgentFeedItem[]): string {
if (run.summary?.trim()) {
return run.summary.trim();
}
const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool');
return latestAssistant?.text || '已完成子任务处理';
}
function useRunCardPhases(runs: ProcessRun[]) {
const [phases, setPhases] = React.useState<Record<string, RunCardPhase>>(() =>
Object.fromEntries(
runs.map((run) => [run.run_id, TERMINAL_STATUSES.has(run.status) ? 'collapsed' : 'live'])
)
);
const timersRef = React.useRef<Record<string, ReturnType<typeof setTimeout>>>({});
React.useEffect(() => {
setPhases((prev) => {
const next = { ...prev };
const seen = new Set<string>();
for (const run of runs) {
seen.add(run.run_id);
const isTerminal = TERMINAL_STATUSES.has(run.status);
const current = next[run.run_id];
if (!current) {
next[run.run_id] = isTerminal ? 'collapsed' : 'live';
continue;
}
if (!isTerminal) {
next[run.run_id] = 'live';
if (timersRef.current[run.run_id]) {
clearTimeout(timersRef.current[run.run_id]);
delete timersRef.current[run.run_id];
}
continue;
}
if (current === 'live') {
next[run.run_id] = 'exiting';
timersRef.current[run.run_id] = setTimeout(() => {
setPhases((snapshot) => {
if (snapshot[run.run_id] !== 'exiting') {
return snapshot;
}
return { ...snapshot, [run.run_id]: 'collapsed' };
});
delete timersRef.current[run.run_id];
}, 420);
}
}
for (const runId of Object.keys(next)) {
if (!seen.has(runId)) {
if (timersRef.current[runId]) {
clearTimeout(timersRef.current[runId]);
delete timersRef.current[runId];
}
delete next[runId];
}
}
return next;
});
return () => {
for (const timer of Object.values(timersRef.current)) {
clearTimeout(timer);
}
timersRef.current = {};
};
}, [runs]);
return phases;
}
function AgentBubble({ item }: { item: AgentFeedItem }) {
return (
<div
className={cn(
'rounded-2xl border px-3 py-2 text-[13px] leading-5 transition-colors',
feedTone(item.role),
item.role === 'system' && item.tone ? statusTone(item.tone) : ''
)}
>
<div className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<span>{roleLabel(item.role)}</span>
</div>
<div className="whitespace-pre-wrap break-words">{item.text}</div>
</div>
);
}
function LiveAgentCard({
run,
feed,
artifactCount,
selected,
phase,
accentIndex,
onSelect,
}: {
run: ProcessRun;
feed: AgentFeedItem[];
artifactCount: number;
selected: boolean;
phase: RunCardPhase;
accentIndex: number;
onSelect: () => void;
}) {
const showSpinner = !TERMINAL_STATUSES.has(run.status);
const accent = accentFor(accentIndex);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'min-w-[308px] max-w-[308px] rounded-[22px] border bg-card/70 p-3.5 text-left backdrop-blur-sm transition-all duration-300',
accent.frame,
selected ? 'ring-1 ring-primary/40 shadow-[0_18px_36px_-30px_rgba(15,23,42,0.75)]' : 'hover:border-primary/30',
phase === 'exiting' && 'pointer-events-none scale-[0.94] -translate-y-2 opacity-0'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
<span>Sub-Agent</span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
</div>
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)}
</Badge>
</div>
<div className="mt-3 rounded-[18px] border border-border/60 bg-background/55 p-2.5">
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
{feed.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/60 px-4 py-5 text-center text-sm text-muted-foreground">
agent ...
</div>
)}
{feed.map((item) => (
<AgentBubble key={item.key} item={item} />
))}
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
{showSpinner && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2.5 py-1 text-foreground/80">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
)}
{artifactCount > 0 && <span>{artifactCount} </span>}
{typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>}
</div>
</button>
);
}
function ResultCard({
run,
summary,
artifactCount,
selected,
accentIndex,
onSelect,
}: {
run: ProcessRun;
summary: string;
artifactCount: number;
selected: boolean;
accentIndex: number;
onSelect: () => void;
}) {
const accent = accentFor(accentIndex);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'min-w-[188px] max-w-[228px] rounded-2xl border bg-card/70 px-3.5 py-3 text-left backdrop-blur-sm transition-colors',
accent.result,
selected ? 'ring-1 ring-primary/35' : 'hover:border-primary/25'
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
</div>
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
</div>
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)}
</Badge>
{artifactCount > 0 && <span>{artifactCount} </span>}
</div>
</button>
);
}
export function AgentTeamBlock({
rootRun,
memberRuns,
events,
artifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
events: ProcessEvent[];
artifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const phases = useRunCardPhases(memberRuns);
const sortedRuns = React.useMemo(
() =>
[...memberRuns].sort((a, b) => {
const at = new Date(a.started_at).getTime();
const bt = new Date(b.started_at).getTime();
return at - bt;
}),
[memberRuns]
);
const liveRuns = sortedRuns.filter((run) => phases[run.run_id] === 'live');
const terminalRuns = sortedRuns.filter((run) => TERMINAL_STATUSES.has(run.status));
const collapsedRuns = sortedRuns.filter((run) => phases[run.run_id] === 'collapsed');
const liveCount = liveRuns.filter((run) => !TERMINAL_STATUSES.has(run.status)).length;
const canCancelRoot =
!rootRun.parent_run_id &&
(rootRun.status === 'running' || rootRun.status === 'waiting');
if (liveRuns.length === 0 && terminalRuns.length > 0) {
return (
<div className="inline-flex max-w-full flex-wrap items-start gap-2 rounded-2xl border border-border/60 bg-card/35 px-3 py-3 backdrop-blur-sm">
<div className="mr-1 flex min-h-[68px] min-w-[132px] max-w-[180px] flex-col justify-center">
<div className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
Agent Results
</div>
<div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div>
</div>
{terminalRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<ResultCard
key={run.run_id}
run={run}
summary={runSummary(run, feed)}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
);
}
return (
<div className="overflow-hidden rounded-[24px] border border-border/70 bg-card/45 p-3.5 backdrop-blur-sm shadow-[0_18px_42px_-34px_rgba(0,0,0,0.55)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
Agent Team
</div>
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
<p className="mt-1 text-sm text-muted-foreground">
{liveCount > 0 ? `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent` : '子 agent 已完成,结果已折叠为摘要卡片'}
</p>
</div>
<div className="flex items-center gap-2">
{canCancelRoot && (
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}>
<Square className="mr-1.5 h-3.5 w-3.5" />
</Button>
)}
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
{memberRuns.length} sub-agent
</Badge>
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
{statusLabel(rootRun.status)}
</Badge>
</div>
</div>
{liveRuns.length > 0 && (
<div className="mt-3 -mx-1 overflow-x-auto pb-2">
<div className="flex min-w-full gap-3 px-1">
{liveRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<LiveAgentCard
key={run.run_id}
run={run}
feed={feed}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
phase={phases[run.run_id] || 'live'}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
</div>
)}
{collapsedRuns.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{collapsedRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<ResultCard
key={run.run_id}
run={run}
summary={runSummary(run, feed)}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
)}
</div>
);
}

View File

@ -5,7 +5,6 @@ import React from 'react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MessageList } from '@/components/chat-workbench/MessageList';
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
export function ChatWorkbench({
@ -31,14 +30,15 @@ export function ChatWorkbench({
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const selectedRun = processRuns.find((item) => item.run_id === selectedRunId) || processRuns[0] || null;
const selectedRun = selectedRunId
? processRuns.find((item) => item.run_id === selectedRunId) || null
: null;
const selectedRunEvents = selectedRun
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
: [];
const selectedRunArtifacts = selectedRun
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
: [];
const hasProcessLane = processRuns.length > 0;
const hasResultsPanel = Boolean(
selectedRun &&
(
@ -47,13 +47,9 @@ export function ChatWorkbench({
selectedRunArtifacts.length > 0
)
);
const desktopColumns = hasProcessLane && hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]'
: hasProcessLane
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: 'lg:grid-cols-[minmax(0,1fr)]';
const desktopColumns = hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: 'lg:grid-cols-[minmax(0,1fr)]';
return (
<>
@ -64,19 +60,14 @@ export function ChatWorkbench({
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div>
{hasProcessLane && (
<div className="min-h-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div>
)}
{hasResultsPanel && (
<div className="min-h-0">
<ArtifactSidebar
@ -89,25 +80,24 @@ export function ChatWorkbench({
</div>
<div className="lg:hidden h-full">
{!hasProcessLane && !hasResultsPanel ? (
{!hasResultsPanel ? (
<MessageList
messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
) : (
<Tabs defaultValue="chat" className="h-full flex flex-col">
<div className="px-4 pt-3 border-b border-border">
<TabsList
className={`grid w-full ${
hasProcessLane && hasResultsPanel
? 'grid-cols-3'
: 'grid-cols-2'
}`}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chat"></TabsTrigger>
{hasProcessLane && <TabsTrigger value="process"></TabsTrigger>}
{hasResultsPanel && <TabsTrigger value="results"></TabsTrigger>}
</TabsList>
</div>
@ -117,19 +107,14 @@ export function ChatWorkbench({
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</TabsContent>
{hasProcessLane && (
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</TabsContent>
)}
{hasResultsPanel && (
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar

View File

@ -3,8 +3,9 @@
import React from 'react';
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
import type { ChatMessage } from '@/types';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -108,21 +109,120 @@ function MessageBubble({ message }: { message: ChatMessage }) {
);
}
type AgentTeamGroup = {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
startedAt: string;
};
function parseTimelineTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
const groups = new Map<string, AgentTeamGroup>();
for (const run of processRuns) {
if (run.actor_type !== 'agent') {
continue;
}
let root = run;
const seen = new Set<string>([run.run_id]);
let parentId = run.parent_run_id ?? null;
while (parentId) {
const parent = runMap.get(parentId);
if (!parent || seen.has(parent.run_id)) {
break;
}
root = parent;
seen.add(parent.run_id);
parentId = parent.parent_run_id ?? null;
}
const existing = groups.get(root.run_id);
if (existing) {
existing.memberRuns.push(run);
continue;
}
groups.set(root.run_id, {
rootRun: root,
memberRuns: [run],
startedAt: root.started_at || run.started_at,
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
const at = parseTimelineTime(a.started_at) ?? 0;
const bt = parseTimelineTime(b.started_at) ?? 0;
return at - bt;
}),
}))
.sort((a, b) => {
const at = parseTimelineTime(a.startedAt) ?? 0;
const bt = parseTimelineTime(b.startedAt) ?? 0;
return at - bt;
});
}
export function MessageList({
messages,
isThinking,
messagesEndRef,
viewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
messages: ChatMessage[];
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const teamGroups = React.useMemo(() => buildAgentTeamGroups(processRuns), [processRuns]);
const timelineItems = React.useMemo(() => {
const messageItems = messages.map((message, index) => ({
kind: 'message' as const,
key: `${message.role}:${message.timestamp || index}:${index}`,
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
order: index,
message,
}));
const teamItems = teamGroups.map((group, index) => ({
kind: 'team' as const,
key: `team:${group.rootRun.run_id}`,
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index,
order: messages.length + index,
group,
}));
return [...messageItems, ...teamItems].sort((a, b) => {
if (a.sortTime !== b.sortTime) {
return a.sortTime - b.sortTime;
}
return a.order - b.order;
});
}, [messages, teamGroups]);
return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-4xl mx-auto py-4 space-y-4">
{messages.length === 0 && !isThinking && (
<div className="max-w-6xl mx-auto py-4 space-y-4">
{messages.length === 0 && teamGroups.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
@ -130,9 +230,22 @@ export function MessageList({
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
))}
{timelineItems.map((item) =>
item.kind === 'message' ? (
<MessageBubble key={item.key} message={item.message} />
) : (
<AgentTeamBlock
key={item.key}
rootRun={item.group.rootRun}
memberRuns={item.group.memberRuns}
events={processEvents}
artifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
)
)}
{isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1">

View File

@ -0,0 +1,488 @@
'use client';
import React from 'react';
import type { OfficeMemberView, OfficeTaskStatus, OfficeView, OfficeZoneId } from '@/lib/office';
import { cn } from '@/lib/utils';
type ZoneLayout = {
x: number;
y: number;
width: number;
height: number;
};
const WORLD_WIDTH = 400;
const WORLD_HEIGHT = 225;
const RENDER_SCALE = 2;
const SCENE_WIDTH = WORLD_WIDTH * RENDER_SCALE;
const SCENE_HEIGHT = WORLD_HEIGHT * RENDER_SCALE;
const TILE_SIZE = 16;
const MAP_KEY = 'office-winter-v1';
const TILESET_KEY = 'office-winter-tileset';
const MAP_PATH = '/office/maps/office-winter-v1.tmj';
const TILESET_PATH = '/office/tiles/office-winter-tileset.png';
const PIXEL_AGENTS_BASE = '/office/vendor/pixel-agents/assets';
const FURNITURE_ASSETS = {
deskFront: { key: 'pixel-agents-desk-front', path: `${PIXEL_AGENTS_BASE}/furniture/DESK/DESK_FRONT.png` },
chairFront: { key: 'pixel-agents-chair-front', path: `${PIXEL_AGENTS_BASE}/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png` },
sofaFront: { key: 'pixel-agents-sofa-front', path: `${PIXEL_AGENTS_BASE}/furniture/SOFA/SOFA_FRONT.png` },
tableFront: { key: 'pixel-agents-table-front', path: `${PIXEL_AGENTS_BASE}/furniture/TABLE_FRONT/TABLE_FRONT.png` },
coffeeTable: { key: 'pixel-agents-coffee-table', path: `${PIXEL_AGENTS_BASE}/furniture/COFFEE_TABLE/COFFEE_TABLE.png` },
doubleBookshelf: { key: 'pixel-agents-double-bookshelf', path: `${PIXEL_AGENTS_BASE}/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png` },
pcOn: { key: 'pixel-agents-pc-on', path: `${PIXEL_AGENTS_BASE}/furniture/PC/PC_FRONT_ON_1.png` },
whiteboard: { key: 'pixel-agents-whiteboard', path: `${PIXEL_AGENTS_BASE}/furniture/WHITEBOARD/WHITEBOARD.png` },
} as const;
const CHARACTER_ASSETS = [
{ key: 'pixel-agent-char-0', path: `${PIXEL_AGENTS_BASE}/characters/char_0.png` },
{ key: 'pixel-agent-char-1', path: `${PIXEL_AGENTS_BASE}/characters/char_1.png` },
{ key: 'pixel-agent-char-2', path: `${PIXEL_AGENTS_BASE}/characters/char_2.png` },
{ key: 'pixel-agent-char-3', path: `${PIXEL_AGENTS_BASE}/characters/char_3.png` },
{ key: 'pixel-agent-char-4', path: `${PIXEL_AGENTS_BASE}/characters/char_4.png` },
{ key: 'pixel-agent-char-5', path: `${PIXEL_AGENTS_BASE}/characters/char_5.png` },
] as const;
const CHARACTER_FRAME = {
width: 16,
height: 24,
columnsPerRow: 7,
frontRow: 0,
idleColumns: [0, 1, 2],
};
const ZONE_LAYOUTS: Record<OfficeZoneId, ZoneLayout> = {
reception: { x: 144, y: 28, width: 68, height: 40 },
workspace: { x: 32, y: 28, width: 86, height: 100 },
collab: { x: 152, y: 118, width: 104, height: 62 },
research: { x: 272, y: 28, width: 66, height: 66 },
alert: { x: 284, y: 92, width: 52, height: 54 },
done: { x: 30, y: 154, width: 76, height: 40 },
};
const STATUS_TONES: Record<
OfficeTaskStatus,
{ body: number; outline: number; lamp: number; badge: number; badgeText: string; text: string }
> = {
queued: { body: 0x8aa0b8, outline: 0xe8f0f8, lamp: 0xcbd5e1, badge: 0x31425b, badgeText: 'Q', text: '#e8f0f8' },
running: { body: 0x90caf9, outline: 0xf5faff, lamp: 0xfff59d, badge: 0x4a5a72, badgeText: 'R', text: '#f5faff' },
waiting: { body: 0xd8c79a, outline: 0xfff7ed, lamp: 0xfde68a, badge: 0x7c6843, badgeText: 'W', text: '#fff7ed' },
blocked: { body: 0xd96c75, outline: 0xffe4e6, lamp: 0xffab91, badge: 0x7b3340, badgeText: '!', text: '#fff1f2' },
done: { body: 0x78c27a, outline: 0xe8f5e9, lamp: 0xc5e1a5, badge: 0x44664b, badgeText: 'D', text: '#f0fdf4' },
error: { body: 0xf36d7d, outline: 0xffd1dc, lamp: 0xffab91, badge: 0x7b2634, badgeText: 'X', text: '#fff1f2' },
cancelled: { body: 0x6b7280, outline: 0xe5e7eb, lamp: 0xd1d5db, badge: 0x374151, badgeText: 'S', text: '#f3f4f6' },
};
function groupMembersByZone(members: OfficeMemberView[]) {
const grouped = new Map<OfficeZoneId, OfficeMemberView[]>();
for (const member of members) {
const bucket = grouped.get(member.zoneId);
if (bucket) {
bucket.push(member);
} else {
grouped.set(member.zoneId, [member]);
}
}
return grouped;
}
function zoneGridPoints(layout: ZoneLayout, count: number) {
if (count <= 0) return [];
const innerLeft = layout.x + 12;
const innerTop = layout.y + 14;
const innerWidth = Math.max(layout.width - 24, 10);
const innerHeight = Math.max(layout.height - 20, 10);
const columns = count <= 2 ? count : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const points: Array<{ x: number; y: number }> = [];
for (let index = 0; index < count; index += 1) {
const column = index % columns;
const row = Math.floor(index / columns);
const x = innerLeft + ((column + 0.5) * innerWidth) / columns;
const y = innerTop + ((row + 0.5) * innerHeight) / rows;
points.push({ x: Math.round(x), y: Math.round(y) });
}
return points;
}
function buildMemberPositions(office: OfficeView) {
const grouped = groupMembersByZone(office.members);
const positions = new Map<string, { x: number; y: number }>();
for (const zone of office.zones) {
const layout = ZONE_LAYOUTS[zone.id];
const members = grouped.get(zone.id) ?? [];
const points = zoneGridPoints(layout, members.length);
members.forEach((member, index) => {
positions.set(member.currentRunId, points[index] ?? { x: layout.x + 20, y: layout.y + 20 });
});
}
return positions;
}
function truncateLabel(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.max(1, maxLength - 1))}`;
}
function pickCharacterAsset(member: OfficeMemberView, index: number) {
if (member.isPrimary) return CHARACTER_ASSETS[0];
return CHARACTER_ASSETS[(index % (CHARACTER_ASSETS.length - 1)) + 1];
}
function resolveCharacterPose() {
return {
row: CHARACTER_FRAME.frontRow,
columns: CHARACTER_FRAME.idleColumns,
interval: 220,
};
}
function addFurnitureSprite(scene: any, object: any) {
const x = object.x ?? 0;
const y = object.y ?? 0;
const width = object.width ?? TILE_SIZE;
const height = object.height ?? TILE_SIZE;
const centerX = x + width / 2;
const type = object.type ?? 'anchor';
const addImage = (assetKey: string, px: number, py: number, depth = 20) =>
scene.add.image(px, py, assetKey).setOrigin(0.5, 1).setDepth(depth);
if (type === 'desk-anchor') {
const desk = addImage(FURNITURE_ASSETS.deskFront.key, centerX, y + height + 4);
const pc = addImage(FURNITURE_ASSETS.pcOn.key, centerX, y + height + 2, 21);
return [desk, pc];
}
if (type === 'chair-anchor') return [addImage(FURNITURE_ASSETS.chairFront.key, centerX, y + height + 1)];
if (type === 'sofa-anchor') return [addImage(FURNITURE_ASSETS.sofaFront.key, centerX, y + height)];
if (type === 'coffee-anchor') return [addImage(FURNITURE_ASSETS.coffeeTable.key, centerX, y + height)];
if (type === 'meeting-anchor') return [addImage(FURNITURE_ASSETS.tableFront.key, centerX, y + height + 16)];
if (type === 'server-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)];
if (type === 'archive-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)];
if (type === 'whiteboard-anchor') return [addImage(FURNITURE_ASSETS.whiteboard.key, centerX, y + height)];
return [];
}
export function OfficePhaserCanvas({
office,
selectedRunId,
onRunSelect,
className,
showMetaBar = true,
}: {
office: OfficeView;
selectedRunId: string | null;
onRunSelect: (runId: string) => void;
className?: string;
showMetaBar?: boolean;
}) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const selectRef = React.useRef(onRunSelect);
React.useEffect(() => {
selectRef.current = onRunSelect;
}, [onRunSelect]);
React.useEffect(() => {
let destroyed = false;
let game: any = null;
async function mountScene() {
if (!containerRef.current) return;
const PhaserImport = await import('phaser');
const Phaser = (PhaserImport.default ?? PhaserImport) as any;
if (destroyed || !containerRef.current) return;
const memberPositions = buildMemberPositions(office);
class OfficeScene extends Phaser.Scene {
preload(this: any) {
if (!this.textures.exists(TILESET_KEY)) {
this.load.image(TILESET_KEY, TILESET_PATH);
}
if (!this.cache.tilemap.exists(MAP_KEY)) {
this.load.tilemapTiledJSON(MAP_KEY, MAP_PATH);
}
Object.values(FURNITURE_ASSETS).forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.image(asset.key, asset.path);
}
});
CHARACTER_ASSETS.forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.spritesheet(asset.key, asset.path, {
frameWidth: CHARACTER_FRAME.width,
frameHeight: CHARACTER_FRAME.height,
});
}
});
}
create(this: any) {
this.cameras.main.setBackgroundColor('#1a2433');
this.cameras.main.roundPixels = true;
this.cameras.main.setZoom(RENDER_SCALE);
this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
const map = this.make.tilemap({ key: MAP_KEY });
const tileset = map.addTilesetImage('office-winter-tileset', TILESET_KEY, TILE_SIZE, TILE_SIZE, 0, 0);
if (!tileset) {
throw new Error('Failed to load office-winter-tileset into tilemap');
}
['bg-floor', 'bg-rug', 'walls', 'windows', 'markers'].forEach((layerName, index) => {
const layer = map.createLayer(layerName, tileset, 0, 0);
layer?.setDepth(index);
});
const frame = this.add.rectangle(0, 0, WORLD_WIDTH, WORLD_HEIGHT, 0x000000, 0).setOrigin(0, 0);
frame.setStrokeStyle(4, 0x101827, 1);
frame.setDepth(10);
const objectLayer = map.getObjectLayer('furniture-anchors');
objectLayer?.objects.forEach((object: any) => {
const placed = addFurnitureSprite(this, object);
if (placed.length > 0) return;
const x = object.x ?? 0;
const y = object.y ?? 0;
const width = object.width ?? TILE_SIZE;
const height = object.height ?? TILE_SIZE;
const fallback = this.add.rectangle(x, y, width, height, 0x384b69, 0.18).setOrigin(0, 0);
fallback.setStrokeStyle(2, 0x90caf9, 0.9);
fallback.setDepth(20);
});
const assignmentLines = this.add.graphics();
assignmentLines.setDepth(50);
office.assignments.forEach((assignment) => {
const from = memberPositions.get(assignment.ownerRunId);
if (!from) return;
assignment.assigneeRunIds.forEach((assigneeRunId) => {
const to = memberPositions.get(assigneeRunId);
if (!to) return;
assignmentLines.lineStyle(1, 0xffd166, 0.75);
assignmentLines.beginPath();
assignmentLines.moveTo(from.x, from.y);
assignmentLines.lineTo(to.x, to.y);
assignmentLines.strokePath();
assignmentLines.fillStyle(0xffd166, 1);
assignmentLines.fillRect(to.x - 1, to.y - 1, 2, 2);
});
});
office.members.forEach((member, memberIndex) => {
const point = memberPositions.get(member.currentRunId);
if (!point) return;
const tone = STATUS_TONES[member.status];
const isSelected = selectedRunId === member.currentRunId;
const isPrimary = member.isPrimary;
const container = this.add.container(point.x, point.y);
container.setDepth(60);
const clickTarget = this.add.rectangle(0, 0, isPrimary ? 34 : 30, isPrimary ? 36 : 32, 0x000000, 0.001);
clickTarget.setInteractive({ useHandCursor: true });
clickTarget.setOrigin(0.5);
const shadow = this.add.rectangle(0, 9, isPrimary ? 15 : 13, 4, 0x0f172a, 0.7);
shadow.setOrigin(0.5);
const characterAsset = pickCharacterAsset(member, memberIndex);
const pose = resolveCharacterPose();
let frameIndex = 0;
const character = this.add
.sprite(0, 4, characterAsset.key, 0)
.setDisplaySize(isPrimary ? 24 : 21, isPrimary ? 36 : 32)
.setOrigin(0.5, 1);
const applyCharacterFrame = () => {
const column = pose.columns[frameIndex % pose.columns.length] ?? pose.columns[0] ?? 0;
const frame = pose.row * CHARACTER_FRAME.columnsPerRow + column;
character.setFrame(frame);
frameIndex += 1;
};
applyCharacterFrame();
this.time.addEvent({
delay: pose.interval,
loop: true,
callback: applyCharacterFrame,
});
const highlight = this.add.rectangle(0, -9, isPrimary ? 14 : 12, 19, tone.body, 0.12);
highlight.setStrokeStyle(isSelected ? 2 : 1, isSelected ? 0xfef3c7 : tone.outline, isSelected ? 1 : 0.7);
highlight.setOrigin(0.5);
const lamp = this.add.rectangle(isPrimary ? 8 : 7, -9, 3, 3, tone.lamp, 1);
lamp.setStrokeStyle(1, 0x101827, 1);
lamp.setOrigin(0.5);
const badge = this.add.rectangle(0, -14, isPrimary ? 12 : 10, 5, isPrimary ? 0xffd166 : tone.badge, 1);
badge.setStrokeStyle(1, 0x101827, 1);
badge.setOrigin(0.5);
const badgeText = this.add
.text(0, -16.5, isPrimary ? 'M' : tone.badgeText, {
color: isPrimary ? '#1a2433' : tone.text,
fontFamily: '"Courier New", monospace',
fontSize: '5px',
fontStyle: 'bold',
})
.setOrigin(0.5, 0);
const name = this.add
.text(0, 14, truncateLabel(member.actorName.toUpperCase(), isPrimary ? 10 : 8), {
color: '#f5faff',
fontFamily: '"Courier New", monospace',
fontSize: isPrimary ? '5px' : '4px',
fontStyle: 'bold',
align: 'center',
})
.setOrigin(0.5, 0);
const taskLabel = this.add
.text(0, 20, truncateLabel((member.stageLabel ?? member.currentTitle).toUpperCase(), 12), {
color: '#cbd5e1',
fontFamily: '"Courier New", monospace',
fontSize: '4px',
align: 'center',
})
.setOrigin(0.5, 0);
container.add([clickTarget, shadow, highlight, badge, badgeText, character, lamp, name, taskLabel]);
clickTarget.on('pointerdown', () => {
selectRef.current(member.currentRunId);
});
clickTarget.on('pointerover', () => {
this.tweens.add({ targets: container, scaleX: 1.08, scaleY: 1.08, duration: 90 });
});
clickTarget.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 90 });
});
if (member.status === 'running') {
this.tweens.add({
targets: container,
y: point.y - 1.5,
duration: 500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
this.tweens.add({
targets: lamp,
alpha: 0.2,
duration: 180,
yoyo: true,
repeat: -1,
});
}
if (member.status === 'blocked' || member.status === 'error') {
const warn = this.add
.text(isPrimary ? 8 : 7, -3, '!', {
color: '#fff7ed',
fontFamily: '"Courier New", monospace',
fontSize: '8px',
fontStyle: 'bold',
})
.setOrigin(0.5);
container.add(warn);
this.tweens.add({
targets: warn,
alpha: 0.25,
duration: 180,
yoyo: true,
repeat: -1,
});
}
if (member.status === 'done') {
const doneMark = this.add.rectangle(isPrimary ? 7 : 6, 7, 3, 3, 0x78c27a, 1);
doneMark.setStrokeStyle(1, 0xf0fdf4, 1);
doneMark.setOrigin(0.5);
container.add(doneMark);
}
});
}
}
game = new Phaser.Game({
type: Phaser.CANVAS,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
parent: containerRef.current,
pixelArt: true,
antialias: false,
roundPixels: true,
backgroundColor: '#1a2433',
scene: OfficeScene,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
});
}
mountScene().catch((error) => {
console.error('Failed to mount Office Phaser canvas', error);
});
return () => {
destroyed = true;
game?.destroy(true);
};
}, [office, selectedRunId]);
return (
<div className={cn('space-y-3', className)}>
{showMetaBar ? (
<div className="flex flex-wrap items-center gap-2 text-[#cbd5e1]">
<span className="rounded-none border-2 border-[#5a7092] bg-[#1a2433] px-3 py-1 text-[11px] font-semibold tracking-[0.2em] text-[#f5faff]">
WINTER OFFICE MAP
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
400 x 225 LOGIC / 800 x 450 RENDER
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
{office.members.length} AGENTS
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
{office.assignments.length} LINKS
</span>
</div>
) : null}
<div className="overflow-hidden rounded-none border-4 border-[#0e1119] bg-[#171522] p-3 shadow-[0_0_0_2px_#2a223b_inset]">
<div
className="mx-auto w-full max-w-[1200px] overflow-hidden border-4 border-[#5a7092] bg-[#1a2433]"
style={{ aspectRatio: `${WORLD_WIDTH} / ${WORLD_HEIGHT}` }}
>
<div
ref={containerRef}
className="h-full w-full [&_canvas]:!block [&_canvas]:!h-full [&_canvas]:!w-full [&_canvas]:image-rendering-[pixelated]"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office';
import { officeTaskStatusLabel } from '@/lib/office';
export function OfficeStatusBadge({
status,
className,
}: {
status: OfficeTaskStatus;
className?: string;
}) {
return (
<Badge
variant="outline"
className={cn(
'border text-[11px]',
status === 'done' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
status === 'running' && 'border-sky-500/30 bg-sky-500/10 text-sky-700',
status === 'waiting' && 'border-amber-500/30 bg-amber-500/10 text-amber-700',
status === 'blocked' && 'border-orange-500/30 bg-orange-500/10 text-orange-700',
status === 'queued' && 'border-slate-500/30 bg-slate-500/10 text-slate-700',
status === 'error' && 'border-rose-500/30 bg-rose-500/10 text-rose-700',
status === 'cancelled' && 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700',
className
)}
>
{officeTaskStatusLabel(status)}
</Badge>
);
}
export function formatOfficeTime(value?: string | null): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function formatOfficeDuration(durationMs: number | null): string {
if (durationMs === null || durationMs < 0) return '-';
if (durationMs < 1000) return '<1s';
const seconds = Math.floor(durationMs / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${remainingSeconds}s`;
return `${remainingSeconds}s`;
}
export function progressPercent(value: number | null, max: number | null): number {
if (value === null || max === null || max <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
}
export function zonePanelClassName(zone: OfficeZoneView): string {
return cn(
'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm',
'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]',
zone.tone === 'info' && 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.95),rgba(224,242,254,0.7))]',
zone.tone === 'warn' && 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.95),rgba(254,243,199,0.72))]',
zone.tone === 'danger' && 'border-rose-200 bg-[linear-gradient(180deg,rgba(255,241,242,0.96),rgba(255,228,230,0.76))]',
zone.tone === 'success' && 'border-emerald-200 bg-[linear-gradient(180deg,rgba(236,253,245,0.96),rgba(209,250,229,0.74))]',
zone.tone === 'neutral' && 'border-border bg-card'
);
}

View File

@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Building2, Clock3 } from 'lucide-react';
import { cn } from '@/lib/utils';
const TASK_MANAGEMENT_TABS = [
{
label: 'Office',
href: '/office',
icon: Building2,
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
},
{
label: '定时任务',
href: '/cron',
icon: Clock3,
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
},
] as const;
export function TaskManagementTabs() {
const pathname = usePathname();
return (
<div className="rounded-2xl border border-border/70 bg-muted/20 p-1">
<div className="flex flex-wrap gap-1">
{TASK_MANAGEMENT_TABS.map((tab) => {
const isActive = tab.match(pathname);
const Icon = tab.icon;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
)}
>
<Icon className="h-4 w-4" />
{tab.label}
</Link>
);
})}
</div>
</div>
);
}

View File

@ -27,6 +27,7 @@ import type {
OutlookOverview,
OutlookStatus,
UiAgentDescriptor,
UiSubagentDescriptor,
UiMcpServerDescriptor,
WsEvent,
} from '@/types';
@ -631,6 +632,59 @@ export async function refreshAgents(): Promise<{ agents: UiAgentDescriptor[] }>
return fetchJSON('/api/agents/refresh', { method: 'POST' });
}
export async function listSubagents(): Promise<UiSubagentDescriptor[]> {
return fetchJSON('/api/subagents');
}
export async function createSubagent(payload: {
id: string;
name?: string;
description?: string;
system_prompt?: string;
model?: string;
enabled?: boolean;
delegation_mode?: string;
allow_mcp?: boolean;
tags?: string[];
aliases?: string[];
mcp_servers?: Record<string, Record<string, unknown>>;
metadata?: Record<string, unknown>;
}): Promise<UiSubagentDescriptor> {
return fetchJSON('/api/subagents', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateSubagent(
subagentId: string,
payload: {
id: string;
name?: string;
description?: string;
system_prompt?: string;
model?: string;
enabled?: boolean;
delegation_mode?: string;
allow_mcp?: boolean;
tags?: string[];
aliases?: string[];
mcp_servers?: Record<string, Record<string, unknown>>;
metadata?: Record<string, unknown>;
}
): Promise<UiSubagentDescriptor> {
return fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteSubagent(subagentId: string): Promise<void> {
await fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
method: 'DELETE',
});
}
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',

View File

@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildOfficeTaskList, buildOfficeView } from '@/lib/office';
import type { ProcessArtifact, ProcessEvent, ProcessRun, Session } from '@/types';
describe('office view builders', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('builds an office view from a root run tree', () => {
const sessions: Session[] = [
{
key: 'web:default',
path: '需求讨论',
created_at: '2026-03-24T09:55:00.000Z',
updated_at: '2026-03-24T10:10:00.000Z',
},
];
const processRuns: ProcessRun[] = [
{
run_id: 'run-root',
parent_run_id: null,
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: '主 Agent',
title: '整理竞品研究并给出结论',
status: 'running',
started_at: '2026-03-24T10:00:00.000Z',
metadata: {
stage_label: '分析结果',
},
},
{
run_id: 'run-sub-agent',
parent_run_id: 'run-root',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'research-agent',
actor_name: 'Research Agent',
title: '收集竞品资料',
status: 'done',
started_at: '2026-03-24T10:01:00.000Z',
finished_at: '2026-03-24T10:04:00.000Z',
summary: '已完成资料收集',
},
{
run_id: 'run-sub-mcp',
parent_run_id: 'run-root',
session_id: 'web:default',
actor_type: 'mcp',
actor_id: 'search-mcp',
actor_name: 'Search MCP',
title: '抓取公开资料',
status: 'running',
started_at: '2026-03-24T10:02:00.000Z',
},
];
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-1',
run_id: 'run-root',
parent_run_id: null,
kind: 'run_progress',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: '主 Agent',
text: '开始归纳公开信息',
created_at: '2026-03-24T10:03:00.000Z',
metadata: {
stage_label: '分析结果',
},
},
{
event_id: 'evt-2',
run_id: 'run-sub-agent',
parent_run_id: 'run-root',
kind: 'run_finished',
actor_type: 'agent',
actor_id: 'research-agent',
actor_name: 'Research Agent',
text: '资料整理完成',
status: 'done',
created_at: '2026-03-24T10:04:00.000Z',
},
{
event_id: 'evt-3',
run_id: 'run-sub-mcp',
parent_run_id: 'run-root',
kind: 'run_progress',
actor_type: 'mcp',
actor_id: 'search-mcp',
actor_name: 'Search MCP',
text: '正在搜索公开网页',
created_at: '2026-03-24T10:05:00.000Z',
},
];
const processArtifacts: ProcessArtifact[] = [
{
artifact_id: 'artifact-1',
run_id: 'run-sub-agent',
actor_type: 'agent',
actor_id: 'research-agent',
actor_name: 'Research Agent',
title: '竞品清单',
artifact_type: 'markdown',
content: '- A\n- B',
created_at: '2026-03-24T10:04:30.000Z',
},
];
const office = buildOfficeView('run-root', {
sessions,
processRuns,
processEvents,
processArtifacts,
});
expect(office).not.toBeNull();
expect(office?.taskId).toBe('run-root');
expect(office?.title).toBe('整理竞品研究并给出结论');
expect(office?.sourceSessionLabel).toBe('需求讨论');
expect(office?.members).toHaveLength(3);
expect(office?.tasks).toHaveLength(3);
expect(office?.assignments).toHaveLength(1);
expect(office?.progress.label).toBe('已完成子任务 1 / 3');
expect(office?.currentStageLabel).toBe('分析结果');
expect(office?.stats.artifactCount).toBe(1);
expect(office?.zones.find((zone) => zone.id === 'workspace')?.memberIds).toContain('main-agent');
expect(office?.zones.find((zone) => zone.id === 'collab')?.memberIds).toContain('research-agent');
expect(office?.zones.find((zone) => zone.id === 'research')?.memberIds).toContain('search-mcp');
});
it('marks stale waiting tasks as blocked and emits alerts', () => {
const processRuns: ProcessRun[] = [
{
run_id: 'run-blocked',
parent_run_id: null,
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: '主 Agent',
title: '等待下游结果',
status: 'waiting',
started_at: '2026-03-24T09:00:00.000Z',
},
];
const office = buildOfficeView('run-blocked', {
sessions: [],
processRuns,
processEvents: [],
processArtifacts: [],
});
expect(office?.status).toBe('blocked');
expect(office?.alerts).toHaveLength(1);
expect(office?.alerts[0].level).toBe('warn');
expect(office?.members[0].zoneId).toBe('collab');
});
it('builds a filtered task list and sorts active tasks ahead of finished ones', () => {
const sessions: Session[] = [
{ key: 'web:alpha', path: 'Alpha Session' },
{ key: 'web:beta', path: 'Beta Session' },
];
const processRuns: ProcessRun[] = [
{
run_id: 'run-active',
parent_run_id: null,
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-a',
actor_name: 'Agent A',
title: '执行活跃任务',
status: 'running',
started_at: '2026-03-24T11:20:00.000Z',
},
{
run_id: 'run-done',
parent_run_id: null,
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-b',
actor_name: 'Agent B',
title: '已结束任务',
status: 'done',
started_at: '2026-03-24T10:00:00.000Z',
finished_at: '2026-03-24T10:08:00.000Z',
},
{
run_id: 'run-other-session',
parent_run_id: null,
session_id: 'web:beta',
actor_type: 'agent',
actor_id: 'agent-c',
actor_name: 'Agent C',
title: '其他会话任务',
status: 'running',
started_at: '2026-03-24T11:00:00.000Z',
},
];
const tasks = buildOfficeTaskList({
sessionId: 'web:alpha',
sessions,
processRuns,
processEvents: [],
processArtifacts: [],
});
expect(tasks).toHaveLength(2);
expect(tasks[0].taskId).toBe('run-active');
expect(tasks[1].taskId).toBe('run-done');
expect(tasks[0].sessionLabel).toBe('Alpha Session');
});
});

View File

@ -0,0 +1,704 @@
import type {
ProcessActorType,
ProcessArtifact,
ProcessEvent,
ProcessRun,
ProcessRunStatus,
Session,
} from '@/types';
const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']);
const STALE_WAITING_MS = 2 * 60 * 1000;
export type OfficeTaskStatus = ProcessRunStatus | 'blocked';
export type OfficeZoneId =
| 'reception'
| 'workspace'
| 'collab'
| 'research'
| 'alert'
| 'done';
export interface OfficeProgressView {
mode: 'stage' | 'ratio' | 'status';
label: string;
value: number | null;
max: number | null;
stageLabel: string | null;
}
export interface OfficeStatsView {
totalRuns: number;
activeRuns: number;
doneRuns: number;
errorRuns: number;
cancelledRuns: number;
memberCount: number;
artifactCount: number;
}
export interface OfficeZoneView {
id: OfficeZoneId;
label: string;
memberIds: string[];
taskIds: string[];
tone: 'neutral' | 'info' | 'warn' | 'danger' | 'success';
}
export interface OfficeMemberView {
memberId: string;
actorId: string;
actorName: string;
actorType: ProcessActorType;
status: OfficeTaskStatus;
zoneId: OfficeZoneId;
currentRunId: string;
currentTitle: string;
stageLabel: string | null;
summary: string | null;
startedAt: string | null;
updatedAt: string | null;
finishedAt: string | null;
childRunIds: string[];
artifactCount: number;
isPrimary: boolean;
}
export interface OfficeTaskView {
taskId: string;
runId: string;
parentRunId: string | null;
actorId: string;
actorName: string;
actorType: ProcessActorType;
title: string;
status: OfficeTaskStatus;
stageLabel: string | null;
summary: string | null;
startedAt: string;
updatedAt: string;
finishedAt: string | null;
childTaskIds: string[];
artifactCount: number;
errorText: string | null;
isRoot: boolean;
}
export interface OfficeAssignmentView {
ownerRunId: string;
ownerActorName: string;
assigneeRunIds: string[];
assigneeActorNames: string[];
label: string;
}
export interface OfficeAlertView {
id: string;
level: 'info' | 'warn' | 'error';
title: string;
description: string | null;
runId: string | null;
actorId: string | null;
createdAt: string;
}
export interface OfficeView {
officeId: string;
taskId: string;
sessionId: string | null;
title: string;
status: OfficeTaskStatus;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
durationMs: number | null;
sourceSessionLabel: string;
rootRunId: string;
rootActorName: string;
currentStageLabel: string | null;
progress: OfficeProgressView;
stats: OfficeStatsView;
alerts: OfficeAlertView[];
zones: OfficeZoneView[];
members: OfficeMemberView[];
tasks: OfficeTaskView[];
assignments: OfficeAssignmentView[];
detailRunIds: string[];
}
export interface OfficeTaskListItem {
officeId: string;
taskId: string;
sessionId: string | null;
sessionLabel: string;
title: string;
status: OfficeTaskStatus;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
rootRunId: string;
rootActorName: string;
memberCount: number;
activeRuns: number;
errorCount: number;
artifactCount: number;
currentStageLabel: string | null;
progress: OfficeProgressView;
}
type BuildOfficeInput = {
sessions: Session[];
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
};
function toTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function compareIsoDesc(a?: string | null, b?: string | null): number {
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
}
function firstString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function firstNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readMetadataString(metadata: Record<string, unknown> | undefined, keys: string[]): string | null {
for (const key of keys) {
const value = firstString(metadata?.[key]);
if (value) return value;
}
return null;
}
function readMetadataNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
for (const key of keys) {
const value = firstNumber(metadata?.[key]);
if (value !== null) return value;
}
return null;
}
function latestTimestamp(values: Array<string | null | undefined>): string | null {
let selected: string | null = null;
let selectedTime = -1;
for (const value of values) {
const time = toTime(value);
if (time === null || time <= selectedTime) continue;
selected = value ?? null;
selectedTime = time;
}
return selected;
}
function getSessionLabel(sessions: Session[], sessionId: string | null): string {
if (!sessionId) return '未关联会话';
const session = sessions.find((item) => item.key === sessionId);
if (!session) return sessionId;
return session.path?.trim() || session.key;
}
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const collection = map.get(item.run_id);
if (collection) {
collection.push(item);
continue;
}
map.set(item.run_id, [item]);
}
return map;
}
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
const map = new Map<string, ProcessRun[]>();
for (const run of processRuns) {
if (!run.parent_run_id) continue;
const children = map.get(run.parent_run_id);
if (children) {
children.push(run);
continue;
}
map.set(run.parent_run_id, [run]);
}
return map;
}
function findRootRuns(processRuns: ProcessRun[]): ProcessRun[] {
const runIds = new Set(processRuns.map((run) => run.run_id));
return processRuns.filter((run) => !run.parent_run_id || !runIds.has(run.parent_run_id));
}
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
const collected: ProcessRun[] = [];
const stack = [rootRun];
const seen = new Set<string>();
while (stack.length > 0) {
const current = stack.pop();
if (!current || seen.has(current.run_id)) continue;
seen.add(current.run_id);
collected.push(current);
const children = childrenMap.get(current.run_id) ?? [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return collected;
}
function getRunUpdatedAt(
run: ProcessRun,
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
): string {
const eventTimes = (eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at);
const artifactTimes = (artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at);
return (
latestTimestamp([
...eventTimes,
...artifactTimes,
run.finished_at,
run.started_at,
]) ?? run.started_at
);
}
function deriveStageLabel(
run: ProcessRun,
runEvents: ProcessEvent[],
fallbackStatus: OfficeTaskStatus,
): string | null {
const runMetadataLabel = readMetadataString(run.metadata, [
'stage_label',
'stage',
'phase_label',
'step_label',
]);
if (runMetadataLabel) return runMetadataLabel;
const sortedEvents = [...runEvents].sort((a, b) => compareIsoDesc(a.created_at, b.created_at));
for (const event of sortedEvents) {
const label = readMetadataString(event.metadata, [
'stage_label',
'stage',
'phase_label',
'step_label',
]);
if (label) return label;
}
if (fallbackStatus === 'running') return '执行中';
if (fallbackStatus === 'waiting') return '等待中';
if (fallbackStatus === 'queued') return '排队中';
if (fallbackStatus === 'done') return '已完成';
if (fallbackStatus === 'error') return '失败';
if (fallbackStatus === 'cancelled') return '已取消';
if (fallbackStatus === 'blocked') return '阻塞';
return null;
}
function deriveRunStatus(
run: ProcessRun,
updatedAt: string,
now: number,
): OfficeTaskStatus {
if (run.status !== 'waiting') return run.status;
const updatedTime = toTime(updatedAt);
if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) {
return 'blocked';
}
return 'waiting';
}
function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): OfficeZoneId {
if (status === 'queued') return 'reception';
if (status === 'waiting' || status === 'blocked') return actorType === 'mcp' ? 'research' : 'collab';
if (status === 'running') return actorType === 'mcp' ? 'research' : 'workspace';
if (status === 'done') return 'collab';
return 'alert';
}
function zoneLabel(zoneId: OfficeZoneId): string {
if (zoneId === 'reception') return '接待区';
if (zoneId === 'workspace') return '工位区';
if (zoneId === 'collab') return '协作区';
if (zoneId === 'research') return '研究区';
if (zoneId === 'alert') return '异常区';
return '完成区';
}
function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] {
if (zoneId === 'workspace' || zoneId === 'research') return 'info';
if (zoneId === 'collab' || zoneId === 'reception') return 'warn';
if (zoneId === 'alert') return 'danger';
if (zoneId === 'done') return 'success';
return 'neutral';
}
function taskStatusPriority(status: OfficeTaskStatus): number {
if (status === 'running') return 6;
if (status === 'blocked') return 5;
if (status === 'waiting') return 4;
if (status === 'queued') return 3;
if (status === 'error') return 2;
if (status === 'cancelled') return 1;
return 0;
}
function selectDisplayRun(
runs: ProcessRun[],
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
now: number,
): { run: ProcessRun; status: OfficeTaskStatus; updatedAt: string } {
const sorted = [...runs]
.map((run) => {
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
const status = deriveRunStatus(run, updatedAt, now);
return { run, status, updatedAt };
})
.sort((a, b) => {
const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status);
if (byStatus !== 0) return byStatus;
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
return sorted[0];
}
function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | null {
if (run.status !== 'error') return null;
const direct = firstString(run.summary);
if (direct) return direct;
const sortedEvents = [...runEvents].sort((a, b) => compareIsoDesc(a.created_at, b.created_at));
for (const event of sortedEvents) {
if (event.status === 'error' && firstString(event.text)) {
return event.text!.trim();
}
}
return '任务执行失败';
}
function deriveProgress(
rootRun: ProcessRun,
taskRuns: ProcessRun[],
taskViews: OfficeTaskView[],
): OfficeProgressView {
const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']);
const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']);
const stageLabel = readMetadataString(rootRun.metadata, ['stage_label', 'stage', 'phase_label', 'step_label']);
if (stageValue !== null && stageMax !== null && stageMax > 0) {
return {
mode: 'ratio',
label: `阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
value: stageValue,
max: stageMax,
stageLabel,
};
}
const doneRuns = taskRuns.filter((run) => run.status === 'done').length;
if (taskRuns.length > 0) {
return {
mode: 'ratio',
label: `已完成子任务 ${doneRuns} / ${taskRuns.length}`,
value: doneRuns,
max: taskRuns.length,
stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null,
};
}
return {
mode: 'status',
label: '等待任务数据',
value: null,
max: null,
stageLabel,
};
}
function buildAlerts(
taskViews: OfficeTaskView[],
now: number,
): OfficeAlertView[] {
const alerts: OfficeAlertView[] = [];
for (const task of taskViews) {
if (task.status === 'error') {
alerts.push({
id: `error:${task.runId}`,
level: 'error',
title: `${task.actorName} 执行失败`,
description: task.errorText,
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
} else if (task.status === 'blocked') {
alerts.push({
id: `blocked:${task.runId}`,
level: 'warn',
title: `${task.actorName} 长时间等待`,
description: '该任务长时间无更新,可能存在阻塞。',
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
} else if (task.status === 'waiting') {
const updatedTime = toTime(task.updatedAt);
if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) {
alerts.push({
id: `stale:${task.runId}`,
level: 'warn',
title: `${task.actorName} 等待时间偏长`,
description: '该任务仍处于等待态,建议查看详情确认依赖是否卡住。',
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
}
}
}
return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt));
}
function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[]): OfficeZoneView[] {
const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done'];
return ids.map((id) => ({
id,
label: zoneLabel(id),
memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId),
taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId),
tone: zoneTone(id),
}));
}
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>): OfficeAssignmentView[] {
return taskRuns
.filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0)
.map((run) => {
const children = childrenMap.get(run.run_id) ?? [];
return {
ownerRunId: run.run_id,
ownerActorName: run.actor_name,
assigneeRunIds: children.map((item) => item.run_id),
assigneeActorNames: children.map((item) => item.actor_name),
label: `${run.actor_name} 分派了 ${children.length} 个子任务`,
};
});
}
export function isOfficeTaskTerminal(status: OfficeTaskStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
export function officeTaskStatusLabel(status: OfficeTaskStatus): string {
if (status === 'queued') return '排队中';
if (status === 'running') return '进行中';
if (status === 'waiting') return '等待中';
if (status === 'blocked') return '阻塞';
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
return '已取消';
}
export function buildOfficeView(
taskId: string,
input: BuildOfficeInput,
): OfficeView | null {
const { sessions, processRuns, processEvents, processArtifacts } = input;
const runById = new Map(processRuns.map((run) => [run.run_id, run]));
const rootRun = runById.get(taskId);
if (!rootRun) return null;
const childrenMap = buildChildrenMap(processRuns);
const taskRuns = collectRunTree(rootRun, childrenMap);
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
const eventsByRun = groupByRunId(taskEvents);
const artifactsByRun = groupByRunId(taskArtifacts);
const now = Date.now();
const taskViews: OfficeTaskView[] = taskRuns
.map((run) => {
const runEvents = eventsByRun.get(run.run_id) ?? [];
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
const status = deriveRunStatus(run, updatedAt, now);
const stageLabel = deriveStageLabel(run, runEvents, status);
const childTaskIds = (childrenMap.get(run.run_id) ?? [])
.filter((child) => taskRunIds.has(child.run_id))
.map((child) => child.run_id);
return {
taskId: run.run_id,
runId: run.run_id,
parentRunId: run.parent_run_id ?? null,
actorId: run.actor_id,
actorName: run.actor_name,
actorType: run.actor_type,
title: run.title,
status,
stageLabel,
summary: firstString(run.summary),
startedAt: run.started_at,
updatedAt,
finishedAt: run.finished_at ?? null,
childTaskIds,
artifactCount: (artifactsByRun.get(run.run_id) ?? []).length,
errorText: deriveErrorText(run, runEvents),
isRoot: run.run_id === rootRun.run_id,
};
})
.sort((a, b) => {
if (a.isRoot !== b.isRoot) return a.isRoot ? -1 : 1;
if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) {
return isOfficeTaskTerminal(a.status) ? 1 : -1;
}
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
const actorRuns = new Map<string, ProcessRun[]>();
for (const run of taskRuns) {
const collection = actorRuns.get(run.actor_id);
if (collection) {
collection.push(run);
continue;
}
actorRuns.set(run.actor_id, [run]);
}
const members: OfficeMemberView[] = Array.from(actorRuns.entries())
.map(([actorId, runs]) => {
const display = selectDisplayRun(runs, eventsByRun, artifactsByRun, now);
const currentRun = display.run;
const currentTask = taskViews.find((task) => task.runId === currentRun.run_id);
return {
memberId: actorId,
actorId,
actorName: currentRun.actor_name,
actorType: currentRun.actor_type,
status: display.status,
zoneId: mapZoneId(display.status, currentRun.actor_type),
currentRunId: currentRun.run_id,
currentTitle: currentRun.title,
stageLabel: currentTask?.stageLabel ?? null,
summary: currentTask?.summary ?? null,
startedAt: currentRun.started_at ?? null,
updatedAt: display.updatedAt,
finishedAt: currentRun.finished_at ?? null,
childRunIds: (childrenMap.get(currentRun.run_id) ?? []).map((child) => child.run_id),
artifactCount: runs.reduce((count, run) => count + (artifactsByRun.get(run.run_id) ?? []).length, 0),
isPrimary: currentRun.run_id === rootRun.run_id,
};
})
.sort((a, b) => {
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status);
if (byStatus !== 0) return byStatus;
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
const sessionId = rootRun.session_id ?? taskRuns.find((run) => run.session_id)?.session_id ?? null;
const updatedAt = latestTimestamp([
...taskViews.map((task) => task.updatedAt),
rootRun.finished_at,
rootRun.started_at,
]) ?? rootRun.started_at;
const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now);
const alerts = buildAlerts(taskViews, now);
const progress = deriveProgress(rootRun, taskRuns, taskViews);
const sourceSessionLabel = getSessionLabel(sessions, sessionId);
const createdAt = rootRun.started_at;
const finishedAt = rootRun.finished_at ?? null;
const durationStart = toTime(createdAt);
const durationEnd = toTime(finishedAt ?? updatedAt);
const durationMs =
durationStart !== null && durationEnd !== null && durationEnd >= durationStart
? durationEnd - durationStart
: null;
return {
officeId: rootRun.run_id,
taskId: rootRun.run_id,
sessionId,
title: rootRun.title || `Task ${rootRun.run_id.slice(0, 8)}`,
status: derivedRootStatus,
createdAt,
updatedAt,
finishedAt,
durationMs,
sourceSessionLabel,
rootRunId: rootRun.run_id,
rootActorName: rootRun.actor_name,
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus),
progress,
stats: {
totalRuns: taskRuns.length,
activeRuns: taskViews.filter((task) => !isOfficeTaskTerminal(task.status)).length,
doneRuns: taskViews.filter((task) => task.status === 'done').length,
errorRuns: taskViews.filter((task) => task.status === 'error').length,
cancelledRuns: taskViews.filter((task) => task.status === 'cancelled').length,
memberCount: members.length,
artifactCount: taskArtifacts.length,
},
alerts,
zones: buildZones(members, taskViews),
members,
tasks: taskViews,
assignments: buildAssignments(taskRuns, childrenMap),
detailRunIds: taskViews.map((task) => task.runId),
};
}
export function buildOfficeTaskList(
input: BuildOfficeInput & { sessionId?: string | null },
): OfficeTaskListItem[] {
const rootRuns = findRootRuns(input.processRuns);
const filteredRoots = input.sessionId
? rootRuns.filter((run) => run.session_id === input.sessionId)
: rootRuns;
return filteredRoots
.map((rootRun) => buildOfficeView(rootRun.run_id, input))
.filter((office): office is OfficeView => office !== null)
.map((office) => ({
officeId: office.officeId,
taskId: office.taskId,
sessionId: office.sessionId,
sessionLabel: office.sourceSessionLabel,
title: office.title,
status: office.status,
createdAt: office.createdAt,
updatedAt: office.updatedAt,
finishedAt: office.finishedAt,
rootRunId: office.rootRunId,
rootActorName: office.rootActorName,
memberCount: office.members.length,
activeRuns: office.stats.activeRuns,
errorCount: office.stats.errorRuns,
artifactCount: office.stats.artifactCount,
currentStageLabel: office.currentStageLabel,
progress: office.progress,
}))
.sort((a, b) => {
if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) {
return isOfficeTaskTerminal(a.status) ? 1 : -1;
}
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
}

View File

@ -190,6 +190,8 @@ export const useChatStore = create<ChatStore>((set) => ({
? 'run_started'
: event.type === 'process_run_progress'
? 'run_progress'
: event.type === 'process_run_message'
? 'run_message'
: event.type === 'process_run_status'
? 'run_status'
: event.type === 'process_run_artifact'
@ -207,6 +209,7 @@ export const useChatStore = create<ChatStore>((set) => ({
? event.summary
: undefined,
status: 'status' in event ? event.status : undefined,
message_role: 'message_role' in event ? event.message_role : undefined,
metadata: 'metadata' in event ? event.metadata : undefined,
created_at: event.created_at,
});
@ -225,7 +228,6 @@ export const useChatStore = create<ChatStore>((set) => ({
started_at: event.created_at,
metadata: event.metadata,
});
nextSelectedRunId = event.run_id;
}
if (event.type === 'process_run_status') {
@ -257,6 +259,20 @@ export const useChatStore = create<ChatStore>((set) => ({
});
}
if (event.type === 'process_run_message') {
const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, {
run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
actor_type: event.actor_type,
actor_id: event.actor_id,
actor_name: event.actor_name,
title: current?.title || event.actor_name,
status: current?.status || 'running',
started_at: current?.started_at || event.created_at,
});
}
if (event.type === 'process_run_artifact') {
nextArtifacts = upsertArtifact(nextArtifacts, {
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
@ -273,7 +289,6 @@ export const useChatStore = create<ChatStore>((set) => ({
metadata: event.metadata,
created_at: event.created_at,
});
nextSelectedRunId = event.run_id;
}
if (event.type === 'process_run_finished') {

View File

@ -6,6 +6,14 @@ const nextConfig = {
ignoreDuringBuilds: true,
},
images: { unoptimized: true },
webpack: (config) => {
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
phaser3spectorjs: false,
};
return config;
},
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit --incremental false",
"test": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
@ -66,6 +67,7 @@
"next-themes": "^0.3.0",
"pdfmake": "^0.2.20",
"pdfmake-with-chinese-fonts": "^1.0.16",
"phaser": "^3.90.0",
"postcss": "8.4.30",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
@ -88,6 +90,8 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/pdfmake": "^0.2.12"
"@types/pdfmake": "^0.2.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.1"
}
}

View File

@ -0,0 +1,18 @@
# Office Pixel Assets
This folder holds pixel-art resources for the office runtime scene.
Structure:
- `tiles/`: reusable 16x16 room tiles
- `sprites/furniture/`: furniture sprites
- `sprites/agents/`: agent sprite sheets
- `sprites/status/`: small state icons and markers
- `atlas/`: packed atlases and metadata
- `maps/`: Tiled maps and layout sketches
Working rules:
- Logical scene resolution: `400x225`
- Base tile size: `16x16`
- Integer scaling only
- No anti-aliasing
- Prefer a small, coherent set of assets over many low-quality variants

View File

@ -0,0 +1,9 @@
# Atlas
Packed texture atlases and metadata for Phaser.
Possible outputs:
- `office-furniture-atlas.png`
- `office-furniture-atlas.json`
- `office-agents-atlas.png`
- `office-agents-atlas.json`

View File

@ -0,0 +1,18 @@
# Maps
This directory stores map sources and layout drafts.
Expected files later:
- `office-winter-v1.tmj`
- `office-winter-v1.json`
- `office-winter-v1-sketch.md`
Current placeholder map:
- `office-winter-v1.tmj`
- Uses a placeholder reference to `../tiles/office-winter-tileset.png`
- Furniture and decor are currently expressed as `object layers` so they can be replaced by real sprites later
Grid:
- logical scene: `400x225`
- tile size: `16x16`
- working map size: `25x14`

View File

@ -0,0 +1,14 @@
WWWWWWWWWWWWWWWWWWWWWWWWW
W.......WWWWW...........W
W..DCD.............VV...W
W..DCD.............VV...W
W..................VV...W
W..DCD.............AA...W
W..DCD.............AA...W
W.......................W
W.............MMMMM.....W
W..DCD........MMMMM.....W
W..DCD........RRRRR.....W
W.............RRRRR.....W
W.......................W
WWWWWWWWWWWWWWWWWWWWWWWWW

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,54 @@
# Office Winter V1 Sketch
Grid:
- Width: `25`
- Height: `14`
- Tile size: `16x16`
Legend:
- `W`: wall/window band
- `F`: floor
- `R`: rug / lounge
- `D`: workstation desk
- `C`: chair
- `M`: meeting table
- `S`: sofa
- `T`: coffee table
- `V`: server rack / monitor
- `A`: archive crate
- `P`: plant / lamp accent
- `.`: walkable empty floor
Layout:
```text
01 WWWWWWWWWWWWWWWWWWWWWWWWW
02 W.......WWWWW...........W
03 W..DCD.............VV...W
04 W..DCD.............VV...W
05 W..................VV...W
06 W..DCD.............AA...W
07 W..DCD.............AA...W
08 W.......................W
09 W.............MMMMM.....W
10 W..DCD........MMMMM.....W
11 W..DCD........RRRRR.....W
12 W.............RRRRR.....W
13 W.......................W
14 WWWWWWWWWWWWWWWWWWWWWWWWW
```
Zone reading:
- Left block: primary workstation area with four seats
- Center-top: lounge corner with sofa and coffee table
- Center-mid: collaboration table
- Center-late: open rug for agent gathering and delegation moments
- Right block: server / monitoring wall
- Bottom-left: archive zone
- Corners: plants or lamps for warmth and silhouette
Recommended next conversion into Tiled:
1. Build wall and floor layers first
2. Drop furniture as object or top layers
3. Leave open walk lanes around the lounge and meeting table
4. Reserve the center rug as the most readable area for live agent activity

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
# Agent Sprites
Directory for office character sprite sheets.
Naming:
- `agent-main.png`
- `agent-worker.png`
- `agent-visitor.png`
Base frame:
- `16x24`
Minimum animation set:
- `idle`
- `walk`
- `type`
- `blocked`
- `done`
Minimum facing set:
- front
- side
- back

View File

@ -0,0 +1,25 @@
# Furniture Sprites
Directory for standalone furniture sprites.
Naming:
- `desk-workstation.png`
- `chair-office.png`
- `table-meeting.png`
- `sofa-2seat.png`
- `table-coffee.png`
- `rack-server.png`
- `crate-archive.png`
- `lamp-floor.png`
- `plant-office.png`
Suggested sizes:
- Desk: `32x16`
- Chair: `16x16`
- Meeting table: `32x24`
- Sofa: `32x16`
- Coffee table: `16x16`
- Server rack: `16x32`
- Archive crate: `16x16`
- Floor lamp: `16x32`
- Plant: `16x24`

View File

@ -0,0 +1,13 @@
# Status Sprites
Small markers used to express runtime state.
Naming:
- `icon-alert.png`
- `icon-task.png`
- `icon-done.png`
- `icon-wait.png`
- `light-warning.png`
Suggested size:
- `8x8` or `12x12`

View File

@ -0,0 +1,21 @@
# Tiles
Purpose:
- Build the office shell with reusable `16x16` tiles.
First batch:
- `floor-dark`
- `floor-light`
- `wall-main`
- `wall-shadow`
- `window-night`
- `rug-center`
- `rug-edge`
- `trim-border`
Target output:
- `office-winter-tileset.png`
Current generated placeholders:
- `office-winter-tileset.png`
- `office-winter-tileset-preview.png`

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pablo De Lucca
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,17 @@
# Pixel Agents Vendor Assets
Vendored from:
- `https://github.com/pablodelucca/pixel-agents`
Included here for internal, non-commercial use in the office runtime prototype.
Copied content:
- `assets/furniture/`
- `assets/floors/`
- `assets/walls/`
- `assets/characters/`
- upstream `LICENSE`
Current usage:
- Furniture sprites are already mapped into the Phaser office canvas.
- Character sprites are copied locally but not wired into runtime rendering yet.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,92 @@
{
"version": 1,
"cols": 21,
"rows": 22,
"layoutRevision": 1,
"tiles": [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 9, 9, 9, 9, 9, 9, 9, 9, 0, 255,
0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 0, 9, 9, 9, 9, 9, 9, 9, 9, 0, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255
],
"tileColors": [
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":209,"s":39,"b":-25,"c":-80}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":214,"s":30,"b":-100,"c":-55}, null,
{"h":214,"s":30,"b":-100,"c":-55}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":25,"s":48,"b":-43,"c":-88}, {"h":214,"s":30,"b":-100,"c":-55}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":209,"s":0,"b":-16,"c":-8}, {"h":214,"s":30,"b":-100,"c":-55}, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null
],
"furniture": [
{"uid": "f-1773353910654-5cdg", "type": "TABLE_FRONT", "col": 4, "row": 16},
{"uid": "f-1773354646615-jhxl", "type": "COFFEE_TABLE", "col": 14, "row": 14},
{"uid": "f-1773354664329-hxsh", "type": "SOFA_SIDE", "col": 13, "row": 14},
{"uid": "f-1773354665989-zgrw", "type": "SOFA_BACK", "col": 14, "row": 16},
{"uid": "f-1773354668333-lo7w", "type": "SOFA_FRONT", "col": 14, "row": 13},
{"uid": "f-1773354670818-r1q2", "type": "SOFA_SIDE:left", "col": 16, "row": 14},
{"uid": "f-1773354686967-yiua", "type": "HANGING_PLANT", "col": 9, "row": 9},
{"uid": "f-1773354687677-hn2k", "type": "HANGING_PLANT", "col": 1, "row": 9},
{"uid": "f-1773354693077-f7aj", "type": "DOUBLE_BOOKSHELF", "col": 7, "row": 9},
{"uid": "f-1773354700513-f1zs", "type": "DOUBLE_BOOKSHELF", "col": 2, "row": 9},
{"uid": "f-1773354799984-j5ri", "type": "SMALL_PAINTING", "col": 12, "row": 9},
{"uid": "f-1773354827151-yox2", "type": "CLOCK", "col": 5, "row": 9},
{"uid": "f-1773354842615-f5md", "type": "PLANT", "col": 18, "row": 10},
{"uid": "f-1773354861273-67uo", "type": "COFFEE", "col": 14, "row": 15},
{"uid": "f-1773354877474-kt9s", "type": "WOODEN_CHAIR_SIDE", "col": 3, "row": 18},
{"uid": "f-1773354879805-px9b", "type": "WOODEN_CHAIR_SIDE", "col": 3, "row": 16},
{"uid": "f-1773354880309-yphd", "type": "WOODEN_CHAIR_SIDE:left", "col": 7, "row": 16},
{"uid": "f-1773354881902-9m50", "type": "WOODEN_CHAIR_SIDE:left", "col": 7, "row": 18},
{"uid": "f-1773354931010-8vvr", "type": "DESK_FRONT", "col": 2, "row": 12},
{"uid": "f-1773354932396-5uus", "type": "DESK_FRONT", "col": 6, "row": 12},
{"uid": "f-1773356768339-eo6u", "type": "CUSHIONED_BENCH", "col": 3, "row": 14},
{"uid": "f-1773356769007-a8jm", "type": "CUSHIONED_BENCH", "col": 7, "row": 14},
{"uid": "f-1773356781294-b69z", "type": "PC_FRONT_OFF", "col": 7, "row": 12},
{"uid": "f-1773356782055-vp70", "type": "PC_FRONT_OFF", "col": 3, "row": 12},
{"uid": "f-1773356784581-5jw9", "type": "PC_SIDE", "col": 4, "row": 16},
{"uid": "f-1773356785458-pyjn", "type": "PC_SIDE", "col": 4, "row": 18},
{"uid": "f-1773356787060-higb", "type": "PC_SIDE:left", "col": 6, "row": 16},
{"uid": "f-1773356787744-ykrz", "type": "PC_SIDE:left", "col": 6, "row": 18},
{"uid": "f-1773356878781-rncl", "type": "PLANT_2", "col": 11, "row": 10},
{"uid": "f-1773356974812-apra", "type": "LARGE_PAINTING", "col": 14, "row": 9},
{"uid": "f-1773357087399-3kfy", "type": "BIN", "col": 2, "row": 20},
{"uid": "f-1773357989802-thws", "type": "SMALL_TABLE_FRONT", "col": 17, "row": 19},
{"uid": "f-1773358001163-aqv4", "type": "SMALL_TABLE_SIDE", "col": 1, "row": 18},
{"uid": "f-1773358458100-4wm2", "type": "COFFEE", "col": 1, "row": 19},
{"uid": "f-1773358479734-biia", "type": "PLANT_2", "col": 1, "row": 17},
{"uid": "f-1773358485454-id8j", "type": "SMALL_PAINTING_2", "col": 17, "row": 9}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,13 @@
{
"id": "BIN",
"name": "Bin",
"category": "misc",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

View File

@ -0,0 +1,13 @@
{
"id": "BOOKSHELF",
"name": "Bookshelf",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 32,
"height": 16,
"footprintW": 2,
"footprintH": 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

View File

@ -0,0 +1,13 @@
{
"id": "CACTUS",
"name": "Cactus",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 1,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

View File

@ -0,0 +1,13 @@
{
"id": "CLOCK",
"name": "Clock",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

View File

@ -0,0 +1,13 @@
{
"id": "COFFEE",
"name": "Coffee",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": true,
"backgroundTiles": 0,
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,13 @@
{
"id": "COFFEE_TABLE",
"name": "Coffee Table",
"category": "desks",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 32,
"height": 32,
"footprintW": 2,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

View File

@ -0,0 +1,13 @@
{
"id": "CUSHIONED_BENCH",
"name": "Cushioned Bench",
"category": "chairs",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

View File

@ -0,0 +1,44 @@
{
"id": "CUSHIONED_CHAIR",
"name": "Cushioned Chair",
"category": "chairs",
"type": "group",
"groupType": "rotation",
"rotationScheme": "3-way-mirror",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"members": [
{
"type": "asset",
"id": "CUSHIONED_CHAIR_FRONT",
"file": "CUSHIONED_CHAIR_FRONT.png",
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1,
"orientation": "front"
},
{
"type": "asset",
"id": "CUSHIONED_CHAIR_BACK",
"file": "CUSHIONED_CHAIR_BACK.png",
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1,
"orientation": "back"
},
{
"type": "asset",
"id": "CUSHIONED_CHAIR_SIDE",
"file": "CUSHIONED_CHAIR_SIDE.png",
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1,
"orientation": "side",
"mirrorSide": true
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 B

View File

@ -0,0 +1,33 @@
{
"id": "DESK",
"name": "Desk",
"category": "desks",
"type": "group",
"groupType": "rotation",
"rotationScheme": "2-way",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 1,
"members": [
{
"type": "asset",
"id": "DESK_FRONT",
"file": "DESK_FRONT.png",
"width": 48,
"height": 32,
"footprintW": 3,
"footprintH": 2,
"orientation": "front"
},
{
"type": "asset",
"id": "DESK_SIDE",
"file": "DESK_SIDE.png",
"width": 16,
"height": 64,
"footprintW": 1,
"footprintH": 4,
"orientation": "side"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

View File

@ -0,0 +1,13 @@
{
"id": "DOUBLE_BOOKSHELF",
"name": "Double Bookshelf",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 32,
"height": 32,
"footprintW": 2,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

View File

@ -0,0 +1,13 @@
{
"id": "HANGING_PLANT",
"name": "Hanging Plant",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": true,
"backgroundTiles": 0,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,13 @@
{
"id": "LARGE_PAINTING",
"name": "Large Painting",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 32,
"height": 32,
"footprintW": 2,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,13 @@
{
"id": "LARGE_PLANT",
"name": "Large Plant",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 32,
"height": 48,
"footprintW": 2,
"footprintH": 3
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

View File

@ -0,0 +1,88 @@
{
"id": "PC",
"name": "PC",
"category": "electronics",
"type": "group",
"groupType": "rotation",
"rotationScheme": "3-way-mirror",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": true,
"backgroundTiles": 1,
"members": [
{
"type": "group",
"groupType": "state",
"orientation": "front",
"members": [
{
"type": "group",
"groupType": "animation",
"state": "on",
"members": [
{
"type": "asset",
"id": "PC_FRONT_ON_1",
"file": "PC_FRONT_ON_1.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"frame": 0
},
{
"type": "asset",
"id": "PC_FRONT_ON_2",
"file": "PC_FRONT_ON_2.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"frame": 1
},
{
"type": "asset",
"id": "PC_FRONT_ON_3",
"file": "PC_FRONT_ON_3.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"frame": 2
}
]
},
{
"type": "asset",
"id": "PC_FRONT_OFF",
"file": "PC_FRONT_OFF.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"state": "off"
}
]
},
{
"type": "asset",
"id": "PC_BACK",
"file": "PC_BACK.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"orientation": "back"
},
{
"type": "asset",
"id": "PC_SIDE",
"file": "PC_SIDE.png",
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2,
"orientation": "side",
"mirrorSide": true
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

View File

@ -0,0 +1,13 @@
{
"id": "PLANT",
"name": "Plant",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 1,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

View File

@ -0,0 +1,13 @@
{
"id": "PLANT_2",
"name": "Plant",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 1,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

View File

@ -0,0 +1,13 @@
{
"id": "POT",
"name": "Pot",
"category": "decor",
"type": "asset",
"canPlaceOnWalls": false,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 16,
"footprintW": 1,
"footprintH": 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

View File

@ -0,0 +1,13 @@
{
"id": "SMALL_PAINTING",
"name": "Small Painting",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

View File

@ -0,0 +1,13 @@
{
"id": "SMALL_PAINTING_2",
"name": "Small Painting",
"category": "wall",
"type": "asset",
"canPlaceOnWalls": true,
"canPlaceOnSurfaces": false,
"backgroundTiles": 0,
"width": 16,
"height": 32,
"footprintW": 1,
"footprintH": 2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Some files were not shown because too many files have changed in this diff Show More