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

@ -171,6 +171,8 @@ class AgentRegistry:
skills: SkillsLoader | None = None, skills: SkillsLoader | None = None,
allow_skill_cards: bool = True, allow_skill_cards: bool = True,
allow_workspace_agents: bool = True, allow_workspace_agents: bool = True,
include_local_fallback: bool = True,
include_plugin_agents: bool = True,
): ):
self.workspace = workspace self.workspace = workspace
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。 # 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
@ -178,10 +180,14 @@ class AgentRegistry:
self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs()) self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs())
self.allow_skill_cards = allow_skill_cards self.allow_skill_cards = allow_skill_cards
self.allow_workspace_agents = allow_workspace_agents self.allow_workspace_agents = allow_workspace_agents
self.include_local_fallback = include_local_fallback
self.include_plugin_agents = include_plugin_agents
self.workspace_store = WorkspaceAgentStore(workspace) self.workspace_store = WorkspaceAgentStore(workspace)
def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]: def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]:
"""按统一格式列出当前可见 agent。""" """按统一格式列出当前可见 agent。"""
if include_local_fallback is None:
include_local_fallback = self.include_local_fallback
agents: list[AgentDescriptor] = [] agents: list[AgentDescriptor] = []
if self.allow_workspace_agents: if self.allow_workspace_agents:
@ -193,23 +199,24 @@ class AgentRegistry:
agents.append(agent) agents.append(agent)
# plugin agents 本质上是“带独立系统提示词的本地执行器”。 # plugin agents 本质上是“带独立系统提示词的本地执行器”。
for plugin in self.plugins.plugins.values(): if self.include_plugin_agents:
for agent in plugin.agents.values(): for plugin in self.plugins.plugins.values():
agents.append( for agent in plugin.agents.values():
AgentDescriptor( agents.append(
id=f"plugin:{agent.name}", AgentDescriptor(
name=agent.name, id=f"plugin:{agent.name}",
description=agent.description or agent.name, name=agent.name,
source="plugin", description=agent.description or agent.name,
kind="local_prompt", source="plugin",
protocol=None, kind="local_prompt",
plugin_name=agent.plugin_name, protocol=None,
model=agent.model, plugin_name=agent.plugin_name,
system_prompt=agent.system_prompt, model=agent.model,
aliases=[agent.name], system_prompt=agent.system_prompt,
metadata={"plugin_name": agent.plugin_name}, aliases=[agent.name],
metadata={"plugin_name": agent.plugin_name},
)
) )
)
if self.allow_skill_cards: if self.allow_skill_cards:
# skill 里声明的 card 视为远端 A2A agent 的静态入口。 # skill 里声明的 card 视为远端 A2A agent 的静态入口。

View File

@ -10,6 +10,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import re
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -68,6 +69,9 @@ class DelegationManager:
allowed_hosts: list[str] | None = None, allowed_hosts: list[str] | None = None,
authz_config: Any | None = None, authz_config: Any | None = None,
backend_identity: Any | None = None, backend_identity: Any | None = None,
allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True,
allow_local_fallback: bool = True,
): ):
self.provider = provider self.provider = provider
self.workspace = workspace self.workspace = workspace
@ -76,6 +80,9 @@ class DelegationManager:
# local_executor 只负责“本地执行”,不再承担队列编排职责。 # local_executor 只负责“本地执行”,不再承担队列编排职责。
self.local_executor = local_executor self.local_executor = local_executor
self.max_parallel_agents = max(1, max_parallel_agents) self.max_parallel_agents = max(1, max_parallel_agents)
self.allow_local_delegation = allow_local_delegation
self.allow_plugin_delegation = allow_plugin_delegation
self.allow_local_fallback = allow_local_fallback
# A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。 # A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。
self.a2a_client = A2AClient( self.a2a_client = A2AClient(
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
@ -88,6 +95,19 @@ class DelegationManager:
self._running_tasks: dict[str, DelegationRun] = {} self._running_tasks: dict[str, DelegationRun] = {}
self._direct_announcement_callback: DirectAnnouncementCallback | None = None self._direct_announcement_callback: DirectAnnouncementCallback | None = None
_PERSISTENT_SUBAGENT_PATTERNS = (
re.compile(r"\bsub[\s-]?agent\b", re.IGNORECASE),
re.compile(r"\bpersistent\b", re.IGNORECASE),
re.compile(r"\bagents\.json\b", re.IGNORECASE),
re.compile(r"\bregistry\.json\b", re.IGNORECASE),
re.compile(r"\bsubagentctl\b", re.IGNORECASE),
re.compile(r"/api/subagents", re.IGNORECASE),
re.compile(r"workspace/agents", re.IGNORECASE),
re.compile(r"子智能体"),
re.compile(r"子 agent", re.IGNORECASE),
re.compile(r"持久化"),
)
def set_direct_announcement_callback( def set_direct_announcement_callback(
self, self,
callback: DirectAnnouncementCallback | None, callback: DirectAnnouncementCallback | None,
@ -160,6 +180,7 @@ class DelegationManager:
label: str, label: str,
*, *,
parent_run_id: str | None = None, parent_run_id: str | None = None,
task: str | None = None,
) -> None: ) -> None:
# 单 agent 执行开始事件,供前端画执行树。 # 单 agent 执行开始事件,供前端画执行树。
await emit_process_event( await emit_process_event(
@ -177,8 +198,21 @@ class DelegationManager:
"protocol": descriptor.protocol, "protocol": descriptor.protocol,
"support_group": descriptor.support_group, "support_group": descriptor.support_group,
"support_streaming": descriptor.support_streaming, "support_streaming": descriptor.support_streaming,
"delegated_task": task,
}, },
) )
if task:
await emit_process_event(
"process_run_message",
run_id=run_id,
parent_run_id=parent_run_id,
actor_type="agent",
actor_id=descriptor.id,
actor_name=descriptor.name,
message_role="user",
text=task,
metadata={"source": "delegation_input"},
)
async def _emit_agent_finished( async def _emit_agent_finished(
self, self,
@ -386,7 +420,7 @@ class DelegationManager:
# 单 agent 场景先解析目标,再执行。 # 单 agent 场景先解析目标,再执行。
descriptor = self._resolve_single(task, target, strategy) descriptor = self._resolve_single(task, target, strategy)
await self._emit_agent_started(run_id, descriptor, label) await self._emit_agent_started(run_id, descriptor, label, task=task)
progress_callback = self._build_progress_callback( progress_callback = self._build_progress_callback(
origin, origin,
descriptor, descriptor,
@ -468,15 +502,20 @@ class DelegationManager:
descriptor = self.registry.get_agent(target) descriptor = self.registry.get_agent(target)
if descriptor is None: if descriptor is None:
raise ValueError(f"Agent '{target}' not found") raise ValueError(f"Agent '{target}' not found")
self._ensure_descriptor_allowed(descriptor)
return descriptor return descriptor
if strategy == "local": if strategy == "local":
if not self.allow_local_fallback:
raise ValueError("Local fallback delegation is disabled")
descriptor = self.registry.get_agent("local-subagent") descriptor = self.registry.get_agent("local-subagent")
if descriptor is None: if descriptor is None:
raise ValueError("Local subagent is not available") raise ValueError("Local subagent is not available")
return descriptor return descriptor
if strategy == "plugin": if strategy == "plugin":
if not self.allow_plugin_delegation:
raise ValueError("Plugin delegation is disabled")
suggestions = [ suggestions = [
agent for agent in self.registry.suggest_agents(task) agent for agent in self.registry.suggest_agents(task)
if agent.kind == "local_prompt" and agent.source == "plugin" if agent.kind == "local_prompt" and agent.source == "plugin"
@ -494,15 +533,46 @@ class DelegationManager:
return suggestions[0] return suggestions[0]
raise ValueError("No matching A2A agent found") raise ValueError("No matching A2A agent found")
suggestions = self.registry.suggest_agents(task, limit=1) # Persistent sub-agent 管理是本地工作区变更任务,必须留在本地执行,
# 不能自动委派给远端 A2A agent否则远端看不到本地规范和状态。
if self._is_persistent_subagent_task(task):
if not self.allow_local_fallback:
raise ValueError("Persistent sub-agent management requires local fallback delegation")
descriptor = self.registry.get_agent("local-subagent")
if descriptor is None:
raise ValueError("Local fallback agent is not available")
return descriptor
suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._descriptor_allowed(agent)
]
if suggestions: if suggestions:
return suggestions[0] return suggestions[0]
# 自动路由一个都猜不到时,最后回到本地兜底 agent。 # 自动路由一个都猜不到时,最后回到本地兜底 agent。
if not self.allow_local_fallback:
raise ValueError("No allowed agent found for delegation")
descriptor = self.registry.get_agent("local-subagent") descriptor = self.registry.get_agent("local-subagent")
if descriptor is None: if descriptor is None:
raise ValueError("Local fallback agent is not available") raise ValueError("Local fallback agent is not available")
return descriptor return descriptor
@classmethod
def _is_persistent_subagent_task(cls, task: str) -> bool:
text = (task or "").strip()
if not text:
return False
matched = sum(1 for pattern in cls._PERSISTENT_SUBAGENT_PATTERNS if pattern.search(text))
if matched >= 2:
return True
lowered = text.lower()
return (
("create" in lowered or "update" in lowered or "repair" in lowered or "fix" in lowered)
and ("subagent" in lowered or "sub-agent" in lowered)
)
async def _run_group( async def _run_group(
self, self,
task: str, task: str,
@ -520,7 +590,10 @@ class DelegationManager:
resolved_targets.append(target) resolved_targets.append(target)
if not resolved_targets: if not resolved_targets:
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。 # 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents) suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2)
if self._descriptor_allowed(agent)
]
resolved_targets = [agent.id for agent in suggestions] resolved_targets = [agent.id for agent in suggestions]
if not resolved_targets: if not resolved_targets:
raise ValueError("No agents available for group delegation") raise ValueError("No agents available for group delegation")
@ -533,6 +606,7 @@ class DelegationManager:
if descriptor is None: if descriptor is None:
missing.append(item) missing.append(item)
else: else:
self._ensure_descriptor_allowed(descriptor)
descriptors.append(descriptor) descriptors.append(descriptor)
if missing: if missing:
raise ValueError(f"Agent(s) not found: {', '.join(missing)}") raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
@ -544,7 +618,13 @@ class DelegationManager:
child_run_id = new_run_id("agent") child_run_id = new_run_id("agent")
async with semaphore: async with semaphore:
try: try:
await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id) await self._emit_agent_started(
child_run_id,
descriptor,
label,
parent_run_id=run_id,
task=task,
)
result = await self._execute_descriptor( result = await self._execute_descriptor(
descriptor, descriptor,
task, task,
@ -588,6 +668,12 @@ class DelegationManager:
"""根据 descriptor 类型执行具体 agent。""" """根据 descriptor 类型执行具体 agent。"""
logger.info("Delegating '{}' to {}", label, descriptor.id) logger.info("Delegating '{}' to {}", label, descriptor.id)
if descriptor.kind in {"local_fallback", "local_prompt"}: if descriptor.kind in {"local_fallback", "local_prompt"}:
if not self.allow_local_delegation or (
descriptor.kind == "local_prompt" and not self.allow_plugin_delegation
) or (
descriptor.kind == "local_fallback" and not self.allow_local_fallback
):
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
# 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。 # 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。
with process_run_context(process_run_id): with process_run_context(process_run_id):
return await self.local_executor.run_local_task( return await self.local_executor.run_local_task(
@ -611,6 +697,19 @@ class DelegationManager:
) )
raise ValueError(f"Unsupported agent kind '{descriptor.kind}'") raise ValueError(f"Unsupported agent kind '{descriptor.kind}'")
def _descriptor_allowed(self, descriptor: AgentDescriptor) -> bool:
if descriptor.kind == "local_fallback":
return self.allow_local_fallback and self.allow_local_delegation
if descriptor.kind == "local_prompt":
return self.allow_local_delegation and self.allow_plugin_delegation
if descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote":
return True
return False
def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
if not self._descriptor_allowed(descriptor):
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
def _build_progress_callback( def _build_progress_callback(
self, self,
origin: dict[str, str], origin: dict[str, str],

View File

@ -76,6 +76,13 @@ class AgentLoop:
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
authz_config: Any | None = None, authz_config: Any | None = None,
backend_identity: Any | None = None, backend_identity: Any | None = None,
allow_spawn: bool = True,
allow_message: bool = True,
allow_cron: bool = True,
include_local_fallback: bool = True,
allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True,
include_plugin_agents: bool = True,
): ):
from nanobot.config.schema import A2AConfig, ExecToolConfig from nanobot.config.schema import A2AConfig, ExecToolConfig
# 基础依赖与运行参数。 # 基础依赖与运行参数。
@ -95,6 +102,13 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.authz_config = authz_config self.authz_config = authz_config
self.backend_identity = backend_identity self.backend_identity = backend_identity
self.allow_spawn = allow_spawn
self.allow_message = allow_message
self.allow_cron = allow_cron
self.include_local_fallback = include_local_fallback
self.allow_local_delegation = allow_local_delegation
self.allow_plugin_delegation = allow_plugin_delegation
self.include_plugin_agents = include_plugin_agents
# 核心组件:上下文构建、会话管理、工具注册、子代理管理。 # 核心组件:上下文构建、会话管理、工具注册、子代理管理。
self.plugins = PluginLoader(workspace) self.plugins = PluginLoader(workspace)
@ -106,6 +120,8 @@ class AgentLoop:
skills=self.skills, skills=self.skills,
allow_skill_cards=self.a2a_config.allow_skill_cards, allow_skill_cards=self.a2a_config.allow_skill_cards,
allow_workspace_agents=self.a2a_config.allow_workspace_agents, allow_workspace_agents=self.a2a_config.allow_workspace_agents,
include_local_fallback=self.include_local_fallback,
include_plugin_agents=self.include_plugin_agents,
) )
self.context = ContextBuilder( self.context = ContextBuilder(
workspace, workspace,
@ -137,6 +153,9 @@ class AgentLoop:
allowed_hosts=self.a2a_config.allowed_hosts, allowed_hosts=self.a2a_config.allowed_hosts,
authz_config=self.authz_config, authz_config=self.authz_config,
backend_identity=self.backend_identity, backend_identity=self.backend_identity,
allow_local_delegation=self.allow_local_delegation,
allow_plugin_delegation=self.allow_plugin_delegation,
allow_local_fallback=self.include_local_fallback,
) )
# 运行时状态位。 # 运行时状态位。
@ -193,11 +212,13 @@ class AgentLoop:
# 网络、消息、子代理工具按职责注册。 # 网络、消息、子代理工具按职责注册。
self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool()) self.tools.register(WebFetchTool())
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) if self.allow_message:
self.tools.register(SpawnTool(manager=self.delegation)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
if self.allow_spawn:
self.tools.register(SpawnTool(manager=self.delegation))
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。 # 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
if self.cron_service: if self.cron_service and self.allow_cron:
self.tools.register(CronTool(self.cron_service)) self.tools.register(CronTool(self.cron_service))
async def _connect_mcp(self) -> None: async def _connect_mcp(self) -> None:

View File

@ -236,4 +236,11 @@ You are a delegated agent spawned by the main agent to complete a specific task.
Your workspace is at: {self.workspace} Your workspace is at: {self.workspace}
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
## Special Workflow
- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes.
- For persistent local sub-agents, follow only the canonical workflow from that skill.
- Do not manually create `workspace/agents/<id>/agent.json` as a substitute for a persistent sub-agent.
- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent.
- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/<id>_agent/AGENTS.json`.
When you have completed the task, provide a clear summary of your findings or actions.{extra}""" When you have completed the task, provide a clear summary of your findings or actions.{extra}"""

View File

@ -0,0 +1,259 @@
"""Persistent local sub-agent storage helpers."""
from __future__ import annotations
import json
import re
import shutil
from dataclasses import asdict, dataclass, field
from importlib.resources import files as pkg_files
from pathlib import Path
from typing import Any
from nanobot.config.schema import Config, MCPServerConfig
_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+")
def normalize_subagent_id(value: str) -> str:
normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
if not normalized:
raise ValueError("Sub-agent id is required")
return normalized
@dataclass
class SubagentSpec:
id: str
name: str
description: str
enabled: bool = True
workspace: str = ""
system_prompt: str = ""
model: str | None = None
delegation_mode: str = "remote_a2a_only"
allow_mcp: bool = True
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec":
agent_id = normalize_subagent_id(payload.get("id", ""))
name = str(payload.get("name") or agent_id).strip() or agent_id
description = str(payload.get("description") or name).strip() or name
workspace = str(payload.get("workspace") or "").strip()
if not workspace and workspace_path is not None:
workspace = str(workspace_path)
tags = [str(item).strip() for item in payload.get("tags", []) if str(item).strip()]
aliases = [str(item).strip() for item in payload.get("aliases", []) if str(item).strip()]
mcp_servers = payload.get("mcp_servers", {})
if not isinstance(mcp_servers, dict):
mcp_servers = {}
metadata = payload.get("metadata", {})
if not isinstance(metadata, dict):
metadata = {}
return cls(
id=agent_id,
name=name,
description=description,
enabled=bool(payload.get("enabled", True)),
workspace=workspace,
system_prompt=str(payload.get("system_prompt") or "").strip(),
model=(str(payload.get("model") or "").strip() or None),
delegation_mode=(str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only"),
allow_mcp=bool(payload.get("allow_mcp", True)),
tags=tags,
aliases=aliases,
mcp_servers=mcp_servers,
metadata=metadata,
)
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
if not self.model:
payload["model"] = None
return payload
class LocalSubagentStore:
"""Persist sub-agent definitions under `<workspace>/agents/<id>_agent/`."""
def __init__(self, workspace: Path):
self.workspace = workspace.expanduser().resolve()
self.directory = self.workspace / "agents"
def list_subagents(self) -> list[SubagentSpec]:
if not self.directory.exists():
return []
result: list[SubagentSpec] = []
for child in sorted(self.directory.iterdir()):
agents_json = child / "AGENTS.json"
if not child.is_dir() or not agents_json.exists():
continue
try:
payload = json.loads(agents_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
if not isinstance(payload, dict):
continue
result.append(SubagentSpec.from_dict(payload, workspace_path=child))
return result
def get_subagent(self, agent_id: str) -> SubagentSpec | None:
path = self.agents_json_path(agent_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id))
def upsert_subagent(self, payload: dict[str, Any], config: Config) -> SubagentSpec:
agent_id = normalize_subagent_id(payload.get("id", ""))
workspace_path = self.subagent_dir(agent_id)
spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path)
self._ensure_workspace(workspace_path)
spec.workspace = str(workspace_path)
self._sync_agents_md(workspace_path, spec)
self.agents_json_path(agent_id).write_text(
json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
from nanobot.agent.agent_registry import WorkspaceAgentStore
WorkspaceAgentStore(self.workspace).upsert_agent(self.build_registry_record(spec, config))
return spec
def delete_subagent(self, agent_id: str) -> bool:
agent_id = normalize_subagent_id(agent_id)
target = self.subagent_dir(agent_id)
if not target.exists():
return False
from nanobot.agent.agent_registry import WorkspaceAgentStore
WorkspaceAgentStore(self.workspace).delete_agent(agent_id)
shutil.rmtree(target)
return True
def subagent_dir(self, agent_id: str) -> Path:
return self.directory / f"{normalize_subagent_id(agent_id)}_agent"
def agents_json_path(self, agent_id: str) -> Path:
return self.subagent_dir(agent_id) / "AGENTS.json"
def local_base_url(self, config: Config, agent_id: str) -> str:
return f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{normalize_subagent_id(agent_id)}"
def build_registry_record(self, spec: SubagentSpec, config: Config) -> dict[str, Any]:
base_url = self.local_base_url(config, spec.id)
card_url = f"{base_url}/.well-known/agent-card"
return {
"id": spec.id,
"name": spec.name,
"description": spec.description,
"protocol": "a2a",
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": card_url,
"enabled": spec.enabled,
"tags": sorted(set(["local-subagent", *spec.tags])),
"aliases": sorted(set([spec.name, *spec.aliases])),
"metadata": {
**spec.metadata,
"workspace": spec.workspace,
"managed_by": "subagent-manager",
"local_subagent": True,
},
"capabilities": {"streaming": False},
"support_group": False,
"support_streaming": False,
}
@staticmethod
def build_agent_card(spec: SubagentSpec, config: Config) -> dict[str, Any]:
base_url = f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{spec.id}"
rpc_url = f"{base_url}/rpc"
return {
"id": spec.id,
"name": spec.name,
"description": spec.description,
"url": rpc_url,
"preferred_transport": "jsonrpc",
"interfaces": [{"transport": "jsonrpc", "url": rpc_url}],
"capabilities": {"streaming": False},
"tags": sorted(set(["local-subagent", *spec.tags])),
"metadata": {
"workspace": spec.workspace,
"managed_by": "subagent-manager",
},
}
@staticmethod
def coerce_mcp_servers(spec: SubagentSpec) -> dict[str, MCPServerConfig]:
if not spec.allow_mcp:
return {}
result: dict[str, MCPServerConfig] = {}
for name, payload in spec.mcp_servers.items():
if not isinstance(payload, dict):
continue
try:
result[name] = MCPServerConfig.model_validate(payload)
except Exception:
continue
return result
def _ensure_workspace(self, workspace_path: Path) -> None:
workspace_path.mkdir(parents=True, exist_ok=True)
templates_dir = pkg_files("nanobot") / "templates"
for item in templates_dir.iterdir():
if not item.name.endswith(".md") or item.name == "AGENTS.md":
continue
dest = workspace_path / item.name
if not dest.exists():
dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
memory_dir = workspace_path / "memory"
memory_dir.mkdir(exist_ok=True)
memory_template = templates_dir / "memory" / "MEMORY.md"
memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists():
memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("", encoding="utf-8")
(workspace_path / "skills").mkdir(exist_ok=True)
def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None:
content = self._render_agents_md(spec)
(workspace_path / "AGENTS.md").write_text(content, encoding="utf-8")
@staticmethod
def _render_agents_md(spec: SubagentSpec) -> str:
prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely."
return f"""# {spec.name}
You are {spec.name}, a persistent local sub-agent managed by Boardware Genius.
## Role
{spec.description}
## System Prompt
{prompt}
## Constraints
- Work only inside this workspace.
- Respond only to delegated tasks.
- Delegate only to remote A2A agents when delegation is enabled.
- Do not create or manage local sub-agents.
- Do not message end users directly.
"""

View File

@ -0,0 +1,82 @@
---
name: subagent-manager
description: Create, inspect, update, and remove persistent local A2A sub-agents. Use when the user wants Boardware Genius to manage sub-agents with their own workspace under ~/.nanobot/workspace/agents/<id>_agent, their own AGENTS.json and AGENTS.md, and local A2A visibility in the agent list.
---
# Subagent Manager
Use this skill when the user wants to create or manage a persistent local sub-agent.
## Required Rules
- Persistent sub-agents must be created and updated only through `subagentctl.py` or `/api/subagents`.
- Treat `~/.nanobot/workspace/agents/<id>_agent/AGENTS.json` as the source of truth.
- Do not create a sub-agent by manually editing `workspace/agents/registry.json`.
- Do not create ad-hoc layouts such as `workspace/agents/<id>/agent.json`, `main.py`, or `README.md` as a substitute for a persistent sub-agent.
- Do not write `protocol: "local"` registry records for persistent sub-agents. A valid persistent sub-agent is registered automatically as local A2A with `protocol: "a2a"`.
- Prefer the bundled script over hand-editing JSON files, because the script keeps `AGENTS.json`, `AGENTS.md`, and the registry entry consistent.
## Workflow
1. Inspect the current sub-agents first:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py list`
2. Create or update the sub-agent with:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create ...`
3. Verify the generated workspace:
`~/.nanobot/workspace/agents/<id>_agent/`
4. Verify the agent registry entry exists by checking `/api/agents` or `workspace/agents/registry.json`.
5. If the user wants custom skills, edit files under:
`~/.nanobot/workspace/agents/<id>_agent/skills/`
## Creation Standard
When the user asks for a new specialized sub-agent, always:
1. Choose a stable kebab-case id.
2. Create it with `subagentctl.py create` or `POST /api/subagents`.
3. Confirm the generated workspace is `~/.nanobot/workspace/agents/<id>_agent/`.
4. Confirm `AGENTS.json` exists in that directory.
5. Confirm the unified agent list shows the same id as a managed sub-agent entry.
If the user asks for "an agent for X", interpret that as a persistent sub-agent when they want a reusable local worker with its own prompt, memory, skills, or MCP setup.
## Repair Standard
If you find a malformed "sub-agent" created through the wrong path, repair it instead of reusing the broken layout:
1. Read any existing metadata that is useful, such as id, name, description, prompt, tags, aliases, or MCP config.
2. Recreate the agent through `subagentctl.py create` or `/api/subagents`.
3. Verify the new canonical directory `~/.nanobot/workspace/agents/<id>_agent/AGENTS.json`.
4. Remove the malformed directory or stale registry entry only after the canonical sub-agent exists.
Malformed examples include:
- `workspace/agents/<id>/agent.json`
- registry entries with `protocol: "local"`
- agent folders that do not contain `AGENTS.json`
## Commands
- Create:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create --id research-agent --name "Research Agent" --description "Research-focused local A2A sub-agent" --system-prompt "Focus on research tasks and be concise."`
- Show:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py show research-agent`
- Delete:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py delete research-agent`
- Set system prompt:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py set-system-prompt research-agent --text "New prompt"`
- Add HTTP MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-http research-agent --server-id docs --url http://127.0.0.1:9000/mcp`
- Add stdio MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-stdio research-agent --server-id localtools --command npx --arg -y --arg @modelcontextprotocol/server-filesystem`
- Remove MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py remove-mcp research-agent --server-id docs`
## Notes
- `AGENTS.json` is the machine-readable source of truth for the sub-agent.
- `AGENTS.md` is regenerated from `AGENTS.json` when the script updates the sub-agent.
- Builtin skills remain available automatically. Workspace-specific skills live under the sub-agent workspace `skills/` directory.
- This MVP exposes the sub-agent through local A2A `message/send` only.
- New sub-agents default to `delegation_mode="remote_a2a_only"`: they can delegate outward only to remote A2A agents, not to local fallback or plugin agents.
- A valid persistent sub-agent should appear in both `/api/subagents` and `/api/agents`.

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Manage persistent local sub-agents."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Any
ROOT = Path(__file__).resolve().parents[4]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from nanobot.agent.subagents import LocalSubagentStore, SubagentSpec
from nanobot.config.loader import load_config
def _store():
config = load_config()
return config, LocalSubagentStore(config.workspace_path)
def _print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, ensure_ascii=False))
def _load_spec_or_die(store: LocalSubagentStore, agent_id: str) -> SubagentSpec:
spec = store.get_subagent(agent_id)
if spec is None:
raise SystemExit(f"Sub-agent not found: {agent_id}")
return spec
def _parse_key_values(items: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for item in items:
if "=" not in item:
raise SystemExit(f"Expected KEY=VALUE, got: {item}")
key, value = item.split("=", 1)
key = key.strip()
if not key:
raise SystemExit(f"Invalid empty key in: {item}")
result[key] = value
return result
def cmd_list(_: argparse.Namespace) -> None:
_, store = _store()
_print_json([spec.to_dict() for spec in store.list_subagents()])
def cmd_show(args: argparse.Namespace) -> None:
_, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
_print_json(spec.to_dict())
def cmd_create(args: argparse.Namespace) -> None:
config, store = _store()
current = store.get_subagent(args.agent_id)
payload = current.to_dict() if current is not None else {"id": args.agent_id}
payload.update({
"id": args.agent_id,
"name": args.name or payload.get("name") or args.agent_id,
"description": args.description or payload.get("description") or args.name or args.agent_id,
"enabled": not args.disabled,
"delegation_mode": payload.get("delegation_mode") or "remote_a2a_only",
})
if args.system_prompt:
payload["system_prompt"] = args.system_prompt
if args.model:
payload["model"] = args.model
spec = store.upsert_subagent(payload, config)
_print_json(spec.to_dict())
def cmd_delete(args: argparse.Namespace) -> None:
_, store = _store()
deleted = store.delete_subagent(args.agent_id)
if not deleted:
raise SystemExit(f"Sub-agent not found: {args.agent_id}")
_print_json({"ok": True, "id": args.agent_id})
def cmd_set_system_prompt(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload["system_prompt"] = args.text
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_add_mcp_http(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload.setdefault("mcp_servers", {})
payload["mcp_servers"][args.server_id] = {
"url": args.url,
"headers": _parse_key_values(args.header),
"auth_mode": args.auth_mode,
"auth_audience": args.auth_audience,
"auth_scopes": list(args.auth_scope),
"tool_timeout": args.tool_timeout,
"sensitive": args.sensitive,
}
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_add_mcp_stdio(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload.setdefault("mcp_servers", {})
payload["mcp_servers"][args.server_id] = {
"command": args.command,
"args": list(args.arg),
"env": _parse_key_values(args.env),
"auth_mode": args.auth_mode,
"auth_audience": args.auth_audience,
"auth_scopes": list(args.auth_scope),
"tool_timeout": args.tool_timeout,
"sensitive": args.sensitive,
}
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_remove_mcp(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
mcp_servers = payload.setdefault("mcp_servers", {})
mcp_servers.pop(args.server_id, None)
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage persistent local sub-agents")
sub = parser.add_subparsers(dest="command", required=True)
list_parser = sub.add_parser("list", help="List sub-agents")
list_parser.set_defaults(func=cmd_list)
show_parser = sub.add_parser("show", help="Show one sub-agent")
show_parser.add_argument("agent_id")
show_parser.set_defaults(func=cmd_show)
create_parser = sub.add_parser("create", help="Create or update a sub-agent")
create_parser.add_argument("--id", dest="agent_id", required=True)
create_parser.add_argument("--name", default="")
create_parser.add_argument("--description", default="")
create_parser.add_argument("--system-prompt", default="")
create_parser.add_argument("--model", default="")
create_parser.add_argument("--disabled", action="store_true")
create_parser.set_defaults(func=cmd_create)
delete_parser = sub.add_parser("delete", help="Delete a sub-agent")
delete_parser.add_argument("agent_id")
delete_parser.set_defaults(func=cmd_delete)
prompt_parser = sub.add_parser("set-system-prompt", help="Update the system prompt")
prompt_parser.add_argument("agent_id")
prompt_parser.add_argument("--text", required=True)
prompt_parser.set_defaults(func=cmd_set_system_prompt)
http_parser = sub.add_parser("add-mcp-http", help="Add an HTTP MCP server")
http_parser.add_argument("agent_id")
http_parser.add_argument("--server-id", required=True)
http_parser.add_argument("--url", required=True)
http_parser.add_argument("--header", action="append", default=[])
http_parser.add_argument("--auth-mode", default="none")
http_parser.add_argument("--auth-audience", default="")
http_parser.add_argument("--auth-scope", action="append", default=[])
http_parser.add_argument("--tool-timeout", type=int, default=30)
http_parser.add_argument("--sensitive", action="store_true")
http_parser.set_defaults(func=cmd_add_mcp_http)
stdio_parser = sub.add_parser("add-mcp-stdio", help="Add a stdio MCP server")
stdio_parser.add_argument("agent_id")
stdio_parser.add_argument("--server-id", required=True)
stdio_parser.add_argument("--command", required=True)
stdio_parser.add_argument("--arg", action="append", default=[])
stdio_parser.add_argument("--env", action="append", default=[])
stdio_parser.add_argument("--auth-mode", default="none")
stdio_parser.add_argument("--auth-audience", default="")
stdio_parser.add_argument("--auth-scope", action="append", default=[])
stdio_parser.add_argument("--tool-timeout", type=int, default=30)
stdio_parser.add_argument("--sensitive", action="store_true")
stdio_parser.set_defaults(func=cmd_add_mcp_stdio)
remove_mcp = sub.add_parser("remove-mcp", help="Remove an MCP server")
remove_mcp.add_argument("agent_id")
remove_mcp.add_argument("--server-id", required=True)
remove_mcp.set_defaults(func=cmd_remove_mcp)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

View File

@ -11,6 +11,7 @@ import secrets
import shlex import shlex
import shutil import shutil
import time import time
import uuid
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -436,6 +437,21 @@ class MCPServerRequest(BaseModel):
sensitive: bool = False sensitive: bool = False
class SubagentRequest(BaseModel):
id: str
name: str | None = None
description: str | None = None
system_prompt: str = ""
model: str | None = None
enabled: bool = True
delegation_mode: str = "remote_a2a_only"
allow_mcp: bool = True
tags: list[str] = Field(default_factory=list)
aliases: list[str] = Field(default_factory=list)
mcp_servers: dict[str, dict[str, Any]] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class OutlookConnectionRequest(BaseModel): class OutlookConnectionRequest(BaseModel):
email: str email: str
password: str password: str
@ -733,6 +749,7 @@ def create_app(
app.state.auth_tokens: dict[str, str] = {} app.state.auth_tokens: dict[str, str] = {}
app.state.handoff_codes: dict[str, dict[str, Any]] = {} app.state.handoff_codes: dict[str, dict[str, Any]] = {}
app.state.auth_file = _get_auth_file_path() app.state.auth_file = _get_auth_file_path()
app.state.subagent_tasks: dict[str, dict[str, Any]] = {}
_register_routes(app) _register_routes(app)
return app return app
@ -1083,6 +1100,157 @@ def _register_routes(app: FastAPI) -> None:
backend_identity=config.backend_identity, backend_identity=config.backend_identity,
) )
def _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse:
return JSONResponse(
status_code=200,
content={
"jsonrpc": "2.0",
"id": payload_id,
"error": {"code": code, "message": message},
},
)
def _extract_subagent_task(params: dict[str, Any]) -> str:
message = params.get("message")
if not isinstance(message, dict):
raise ValueError("Missing 'message' object")
parts = message.get("parts")
if isinstance(parts, list):
for part in parts:
if not isinstance(part, dict):
continue
text = str(part.get("text") or "").strip()
if text:
return text
content = message.get("content")
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
text = str(item.get("text") or "").strip()
if text:
return text
raise ValueError("A2A message does not contain text content")
async def _run_subagent_task(agent_id: str, task: str) -> str:
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None or not spec.enabled:
raise HTTPException(status_code=404, detail="Sub-agent not found")
delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower()
allow_spawn = delegation_mode in {"remote_a2a_only", "full"}
allow_local = delegation_mode == "full"
provider = _make_provider(config)
loop = AgentLoop(
bus=app.state.bus,
provider=provider,
workspace=Path(spec.workspace),
model=spec.model or config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
cron_service=None,
restrict_to_workspace=True,
session_manager=SessionManager(Path(spec.workspace)),
mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec),
authz_config=config.authz,
backend_identity=config.backend_identity,
allow_spawn=allow_spawn,
allow_message=False,
allow_cron=False,
include_local_fallback=allow_local,
allow_local_delegation=allow_local,
allow_plugin_delegation=allow_local,
include_plugin_agents=allow_local,
)
try:
return await loop.process_direct(
task,
session_key=f"a2a:{spec.id}",
channel="system",
chat_id=spec.id,
)
finally:
await loop.close_mcp()
def _subagent_task_result(task_id: str) -> dict[str, Any] | None:
payload = app.state.subagent_tasks.get(task_id)
if not isinstance(payload, dict):
return None
result = {
"id": task_id,
"status": payload.get("status", "submitted"),
}
error = str(payload.get("error") or "").strip()
summary = str(payload.get("summary") or "").strip()
if summary:
result["summary"] = summary
if error:
result["summary"] = error
metadata = payload.get("metadata")
if isinstance(metadata, dict) and metadata:
result["metadata"] = metadata
return result
def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None:
payload = app.state.subagent_tasks.get(task_id)
if not isinstance(payload, dict):
return None
task = payload.get("asyncio_task")
if isinstance(task, asyncio.Task) and not task.done():
task.cancel()
payload["status"] = "cancelled"
payload["error"] = ""
payload.setdefault("summary", "Task cancelled")
return _subagent_task_result(task_id)
def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]:
task_id = str(uuid.uuid4())
app.state.subagent_tasks[task_id] = {
"agent_id": agent_id,
"task": task,
"status": "submitted",
}
async def _runner() -> None:
app.state.subagent_tasks[task_id]["status"] = "working"
try:
summary = await _run_subagent_task(agent_id, task)
app.state.subagent_tasks[task_id]["status"] = "completed"
app.state.subagent_tasks[task_id]["summary"] = summary
except asyncio.CancelledError:
app.state.subagent_tasks[task_id]["status"] = "cancelled"
app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled")
raise
except Exception as exc: # noqa: BLE001
app.state.subagent_tasks[task_id]["status"] = "error"
app.state.subagent_tasks[task_id]["error"] = str(exc)
app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner())
return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"}
def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]:
from nanobot.agent.subagents import LocalSubagentStore
payload = spec.to_dict()
base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id)
payload["base_url"] = base_url
payload["endpoint"] = f"{base_url}/rpc"
payload["card_url"] = f"{base_url}/.well-known/agent-card"
return payload
def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str: def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str:
return _require_web_user(app, authorization) return _require_web_user(app, authorization)
@ -1125,6 +1293,98 @@ def _register_routes(app: FastAPI) -> None:
frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host
return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/") return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/")
@app.get("/subagents/{agent_id}/.well-known/agent-card")
@app.get("/subagents/{agent_id}/.well-known/agent-card.json")
@app.get("/subagents/{agent_id}/.well-known/agent.json")
async def get_subagent_card(agent_id: str):
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None or not spec.enabled:
raise HTTPException(status_code=404, detail="Sub-agent not found")
return LocalSubagentStore.build_agent_card(spec, config)
@app.post("/subagents/{agent_id}/rpc")
async def subagent_rpc(agent_id: str, payload: dict[str, Any]):
payload_id = payload.get("id")
method = str(payload.get("method") or "").strip()
params = payload.get("params")
if not isinstance(params, dict):
return _jsonrpc_error(payload_id, -32602, "Invalid params")
if method == "tasks/get":
task_id = str(params.get("id") or "").strip()
if not task_id:
return _jsonrpc_error(payload_id, -32602, "Missing task id")
result = _subagent_task_result(task_id)
if result is None:
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method == "tasks/cancel":
task_id = str(params.get("id") or "").strip()
if not task_id:
return _jsonrpc_error(payload_id, -32602, "Missing task id")
result = _cancel_subagent_task(task_id)
if result is None:
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method == "tasks/send":
try:
task = _extract_subagent_task(params)
except ValueError as exc:
return _jsonrpc_error(payload_id, -32602, str(exc))
result = _start_subagent_task(agent_id, task)
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method != "message/send":
return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found")
try:
task = _extract_subagent_task(params)
except ValueError as exc:
return _jsonrpc_error(payload_id, -32602, str(exc))
try:
response = await _run_subagent_task(agent_id, task)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.exception("Sub-agent RPC failed for {}", agent_id)
return _jsonrpc_error(payload_id, -32000, str(exc))
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {
"message": {
"role": "agent",
"parts": [
{
"type": "text",
"kind": "text",
"text": response,
}
],
}
},
}
def _local_backend_view(config: Config) -> dict[str, Any]: def _local_backend_view(config: Config) -> dict[str, Any]:
return { return {
"backend_id": config.backend_identity.backend_id, "backend_id": config.backend_identity.backend_id,
@ -2473,6 +2733,55 @@ def _register_routes(app: FastAPI) -> None:
}) })
return result return result
@app.get("/api/subagents")
async def list_subagents():
"""List persistent local sub-agents."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
return [_serialize_subagent(spec, config) for spec in store.list_subagents()]
@app.get("/api/subagents/{agent_id}")
async def get_subagent(agent_id: str):
"""Get one persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None:
raise HTTPException(status_code=404, detail="Sub-agent not found")
return _serialize_subagent(spec, config)
@app.post("/api/subagents")
async def create_subagent(req: SubagentRequest):
"""Create or replace a persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.upsert_subagent(req.model_dump(), config)
return _serialize_subagent(spec, config)
@app.put("/api/subagents/{agent_id}")
async def update_subagent(agent_id: str, req: SubagentRequest):
"""Update a persistent local sub-agent."""
if agent_id != req.id:
raise HTTPException(status_code=400, detail="Path id must match body id")
return await create_subagent(req)
@app.delete("/api/subagents/{agent_id}")
async def delete_subagent(agent_id: str):
"""Delete a persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
if store.delete_subagent(agent_id):
return {"ok": True, "id": agent_id}
raise HTTPException(status_code=404, detail="Sub-agent not found")
@app.get("/api/agents") @app.get("/api/agents")
async def list_agents(): async def list_agents():
"""List unified agents from workspace, plugins, skills, and local fallback.""" """List unified agents from workspace, plugins, skills, and local fallback."""

View File

@ -1,11 +1,30 @@
'use client'; 'use client';
import React, { useCallback, useEffect, useState } from 'react'; 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 { useChatStore } from '@/lib/store';
import type { UiAgentDescriptor } from '@/types'; import type { UiAgentDescriptor, UiSubagentDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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'; import { Textarea } from '@/components/ui/textarea';
const EMPTY_FORM = { const EMPTY_AGENT_FORM = {
id: '', id: '',
name: '', name: '',
description: '', description: '',
@ -30,17 +51,69 @@ const EMPTY_FORM = {
aliases: '', 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() { export default function AgentsPage() {
const cachedAgents = useChatStore((s) => s.agentRegistry); const cachedAgents = useChatStore((s) => s.agentRegistry);
const setCachedAgents = useChatStore((s) => s.setAgentRegistry); const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents); const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
const [subagents, setSubagents] = useState<UiSubagentDescriptor[]>([]);
const [loading, setLoading] = useState(cachedAgents.length === 0); const [loading, setLoading] = useState(cachedAgents.length === 0);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false); const [subagentDialogOpen, setSubagentDialogOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [agentSubmitting, setAgentSubmitting] = useState(false);
const [form, setForm] = useState(EMPTY_FORM); 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) => { const load = useCallback(async (background = false) => {
if (background) { if (background) {
@ -50,9 +123,14 @@ export default function AgentsPage() {
} }
setError(null); setError(null);
try { try {
const data = await listAgents(); const [agentData, subagentData] = await Promise.all([
const nextAgents = Array.isArray(data) ? data : []; listAgents(),
listSubagents(),
]);
const nextAgents = Array.isArray(agentData) ? agentData : [];
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
setAgents(nextAgents); setAgents(nextAgents);
setSubagents(nextSubagents);
setCachedAgents(nextAgents); setCachedAgents(nextAgents);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载智能体失败'); setError(err.message || '加载智能体失败');
@ -73,9 +151,14 @@ export default function AgentsPage() {
setError(null); setError(null);
setRefreshing(true); setRefreshing(true);
try { try {
const data = await refreshAgents(); const [agentData, subagentData] = await Promise.all([
const nextAgents = data.agents || []; refreshAgents(),
listSubagents(),
]);
const nextAgents = agentData.agents || [];
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
setAgents(nextAgents); setAgents(nextAgents);
setSubagents(nextSubagents);
setCachedAgents(nextAgents); setCachedAgents(nextAgents);
} catch (err: any) { } catch (err: any) {
setError(err.message || '刷新智能体失败'); setError(err.message || '刷新智能体失败');
@ -84,59 +167,133 @@ export default function AgentsPage() {
} }
}; };
const handleDialogOpenChange = (open: boolean) => { const handleAgentDialogOpenChange = (open: boolean) => {
setDialogOpen(open); setAgentDialogOpen(open);
if (!open) { if (!open) {
setAdvancedOpen(false); setAgentAdvancedOpen(false);
setForm(EMPTY_FORM); 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(); 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) { if (!hasAddress) {
setError('请至少填写 A2A 部署地址、接口地址或卡片地址'); setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
return; return;
} }
setSubmitting(true); setAgentSubmitting(true);
setError(null); setError(null);
try { try {
await addAgent({ await addAgent({
id: form.id || undefined, id: agentForm.id || undefined,
name: form.name || undefined, name: agentForm.name || undefined,
description: form.description || undefined, description: agentForm.description || undefined,
protocol: 'a2a', protocol: 'a2a',
base_url: form.base_url || undefined, base_url: agentForm.base_url || undefined,
endpoint: form.endpoint || undefined, endpoint: agentForm.endpoint || undefined,
card_url: form.card_url || undefined, card_url: agentForm.card_url || undefined,
auth_env: form.auth_env || undefined, auth_env: agentForm.auth_env || undefined,
auth_mode: form.auth_mode || 'none', auth_mode: agentForm.auth_mode || 'none',
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined, auth_audience: agentForm.auth_mode === 'none' ? undefined : agentForm.auth_audience || undefined,
auth_scopes: form.auth_mode === 'none' auth_scopes: agentForm.auth_mode === 'none'
? [] ? []
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean), : agentForm.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean), tags: agentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean), aliases: agentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
}); });
handleDialogOpenChange(false); handleAgentDialogOpenChange(false);
await load(); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '新增智能体失败'); setError(err.message || '新增智能体失败');
} finally { } finally {
setSubmitting(false); setAgentSubmitting(false);
} }
}; };
const handleDelete = async (agentId: string) => { const handleDeleteAgent = async (agentId: string) => {
try { try {
await deleteAgent(agentId); await deleteAgent(agentId);
await load(); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '删除智能体失败'); 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@ -154,17 +311,17 @@ export default function AgentsPage() {
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
A2A Sub-Agent
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={handleRefresh}> <Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button> </Button>
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}> <Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
</Button> </Button>
@ -173,53 +330,51 @@ export default function AgentsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={handleCreate}> <form className="space-y-4" onSubmit={handleCreateAgent}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="base_url">A2A </Label> <Label htmlFor="base_url">A2A </Label>
<Input <Input
id="base_url" id="base_url"
value={form.base_url} value={agentForm.base_url}
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))} onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))}
placeholder="https://agent.example.com 或 agent.example.com:19090" placeholder="https://agent.example.com 或 agent.example.com:19090"
/> />
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
<code className="mx-1">/.well-known/agent-card</code> <code className="mx-1">/.well-known</code>
<code className="mx-1">/.well-known/agent-card.json</code> card
<code className="mx-1">/.well-known/agent.json</code>
ID
</p> </p>
</div> </div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}> <Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between"> <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> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4"> <CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="id">ID</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"></Label> <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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description"></Label> <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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="endpoint"></Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="card_url"></Label> <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> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <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> <Label htmlFor="auth_mode"></Label>
<select <select
id="auth_mode" id="auth_mode"
value={form.auth_mode} value={agentForm.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))} 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" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
> >
<option value="none">none</option> <option value="none">none</option>
@ -237,46 +392,211 @@ export default function AgentsPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_audience">Audience</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_scopes">Scopes</Label> <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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_env"></Label> <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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tags"></Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="aliases"></Label> <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>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
card Sub-Agent Sub-Agent registry
<code className="mx-1">.well-known</code>
</div> </div>
<div className="flex justify-end gap-2"> <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>
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={agentSubmitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />} {agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
</Button> </Button>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </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>
</div> </div>
@ -291,73 +611,178 @@ export default function AgentsPage() {
</Card> </Card>
)} )}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <Tabs defaultValue="agents" className="space-y-4">
{agents.map((agent) => { <TabsList>
const isWorkspace = agent.source === 'workspace'; <TabsTrigger value="agents"></TabsTrigger>
return ( <TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
<Card key={agent.id}> </TabsList>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4"> <TabsContent value="agents" className="space-y-4">
<div className="min-w-0 flex-1"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<CardTitle className="text-base truncate">{agent.name}</CardTitle> {agents.map((agent) => {
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p> const isWorkspace = agent.source === 'workspace';
<p className="text-sm text-muted-foreground mt-2 leading-relaxed"> const isManagedSubagent = Boolean(agent.metadata && agent.metadata.local_subagent);
{agent.description || '—'} return (
</p> <Card key={agent.id}>
</div> <CardHeader className="pb-3">
<div className="flex items-center gap-2 flex-wrap justify-end"> <div className="flex items-start justify-between gap-4">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge> <div className="min-w-0 flex-1">
<Badge variant="secondary">{agent.protocol || '本地'}</Badge> <CardTitle className="text-base truncate">{agent.name}</CardTitle>
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>} <p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
{agent.support_group && <Badge className="bg-emerald-600"></Badge>} <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
</div> {agent.description || '—'}
</div> </p>
</CardHeader> </div>
<CardContent className="space-y-3 pt-0"> <div className="flex items-center gap-2 flex-wrap justify-end">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground"> <Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>} <Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>} {isManagedSubagent && <Badge className="bg-amber-600"> Sub-Agent</Badge>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>} {agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>} {agent.support_group && <Badge className="bg-emerald-600"></Badge>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>} </div>
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>} </div>
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>} </CardHeader>
</div> <CardContent className="space-y-3 pt-0">
{(agent.tags.length > 0 || agent.aliases.length > 0) && ( <div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
<div className="space-y-2"> {agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.tags.length > 0 && ( {agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
<div className="flex items-start gap-2 flex-wrap"> {agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" /> {agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.tags.map((tag) => ( {agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge> {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> </div>
)} {(agent.tags.length > 0 || agent.aliases.length > 0) && (
{agent.aliases.length > 0 && ( <div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> {agent.tags.length > 0 && (
<span className="font-medium text-foreground"></span> <div className="flex items-start gap-2 flex-wrap">
{agent.aliases.map((alias) => ( <Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code> {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>
)} )}
<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>
)} </CardHeader>
<div className="flex justify-end"> <CardContent className="space-y-3 pt-0">
{isWorkspace ? ( <div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}> <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" /> <Trash2 className="w-4 h-4 mr-2" />
</Button> </Button>
) : ( </div>
<span className="text-xs text-muted-foreground"></span> </CardContent>
)} </Card>
</div> ))}
</div>
{subagents.length === 0 && (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Sub-Agent Sub-Agent
</CardContent> </CardContent>
</Card> </Card>
); )}
})} </TabsContent>
</div> </Tabs>
</div> </div>
); );
} }

View File

@ -20,6 +20,7 @@ import {
} from '@/lib/api'; } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -132,6 +133,8 @@ export default function CronPage() {
return ( return (
<div className="max-w-5xl mx-auto p-6 space-y-6"> <div className="max-w-5xl mx-auto p-6 space-y-6">
<TaskManagementTabs />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" /> <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'; 'use client';
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@ -19,6 +21,7 @@ import {
uploadFile, uploadFile,
wsManager, wsManager,
} from '@/lib/api'; } from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types'; import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
@ -133,6 +136,19 @@ export default function ChatPage() {
); );
}, [commands, input]); }, [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 () => { const loadSessions = useCallback(async () => {
try { try {
const list = await listSessions(); const list = await listSessions();
@ -544,6 +560,37 @@ export default function ChatPage() {
</div> </div>
<div className="flex-1 flex flex-col min-w-0"> <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"> <div className="flex-1 min-h-0">
<ChatWorkbench <ChatWorkbench
messages={messages} messages={messages}
@ -554,7 +601,7 @@ export default function ChatPage() {
processEvents={processEvents} processEvents={processEvents}
processArtifacts={processArtifacts} processArtifacts={processArtifacts}
selectedRunId={selectedRunId} selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId} onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
onCancelRun={handleCancelRun} onCancelRun={handleCancelRun}
/> />
</div> </div>

View File

@ -8,10 +8,17 @@ import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen,
import { logout } from '@/lib/api'; import { logout } from '@/lib/api';
import { useChatStore } from '@/lib/store'; 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: '/', icon: MessageSquare },
{ name: '状态', href: '/status', icon: Activity }, { 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: '/skills', icon: Puzzle },
{ name: '插件', href: '/plugins', icon: Blocks }, { name: '插件', href: '/plugins', icon: Blocks },
{ name: '智能体', href: '/agents', icon: Bot }, { name: '智能体', href: '/agents', icon: Bot },
@ -97,7 +104,7 @@ const Header = () => {
const isActive = const isActive =
item.href === '/' item.href === '/'
? pathname === '/' ? pathname === '/'
: pathname.startsWith(item.href); : item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
const Icon = item.icon; const Icon = item.icon;
return ( return (
<Link <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 type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MessageList } from '@/components/chat-workbench/MessageList'; import { MessageList } from '@/components/chat-workbench/MessageList';
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar'; import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
export function ChatWorkbench({ export function ChatWorkbench({
@ -31,14 +30,15 @@ export function ChatWorkbench({
onSelectRun: (runId: string) => void; onSelectRun: (runId: string) => void;
onCancelRun: (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 const selectedRunEvents = selectedRun
? processEvents.filter((item) => item.run_id === selectedRun.run_id) ? processEvents.filter((item) => item.run_id === selectedRun.run_id)
: []; : [];
const selectedRunArtifacts = selectedRun const selectedRunArtifacts = selectedRun
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id) ? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
: []; : [];
const hasProcessLane = processRuns.length > 0;
const hasResultsPanel = Boolean( const hasResultsPanel = Boolean(
selectedRun && selectedRun &&
( (
@ -47,13 +47,9 @@ export function ChatWorkbench({
selectedRunArtifacts.length > 0 selectedRunArtifacts.length > 0
) )
); );
const desktopColumns = hasProcessLane && hasResultsPanel const desktopColumns = hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]' ? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: hasProcessLane : 'lg:grid-cols-[minmax(0,1fr)]';
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
: 'lg:grid-cols-[minmax(0,1fr)]';
return ( return (
<> <>
@ -64,19 +60,14 @@ export function ChatWorkbench({
isThinking={isThinking} isThinking={isThinking}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef} viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/> />
</div> </div>
{hasProcessLane && (
<div className="min-h-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div>
)}
{hasResultsPanel && ( {hasResultsPanel && (
<div className="min-h-0"> <div className="min-h-0">
<ArtifactSidebar <ArtifactSidebar
@ -89,25 +80,24 @@ export function ChatWorkbench({
</div> </div>
<div className="lg:hidden h-full"> <div className="lg:hidden h-full">
{!hasProcessLane && !hasResultsPanel ? ( {!hasResultsPanel ? (
<MessageList <MessageList
messages={messages} messages={messages}
isThinking={isThinking} isThinking={isThinking}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef} 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"> <Tabs defaultValue="chat" className="h-full flex flex-col">
<div className="px-4 pt-3 border-b border-border"> <div className="px-4 pt-3 border-b border-border">
<TabsList <TabsList className="grid w-full grid-cols-2">
className={`grid w-full ${
hasProcessLane && hasResultsPanel
? 'grid-cols-3'
: 'grid-cols-2'
}`}
>
<TabsTrigger value="chat"></TabsTrigger> <TabsTrigger value="chat"></TabsTrigger>
{hasProcessLane && <TabsTrigger value="process"></TabsTrigger>}
{hasResultsPanel && <TabsTrigger value="results"></TabsTrigger>} {hasResultsPanel && <TabsTrigger value="results"></TabsTrigger>}
</TabsList> </TabsList>
</div> </div>
@ -117,19 +107,14 @@ export function ChatWorkbench({
isThinking={isThinking} isThinking={isThinking}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef} viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/> />
</TabsContent> </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 && ( {hasResultsPanel && (
<TabsContent value="results" className="flex-1 min-h-0 mt-0"> <TabsContent value="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar <ArtifactSidebar

View File

@ -3,8 +3,9 @@
import React from 'react'; import React from 'react';
import { Bot, Loader2, Paperclip, User } from 'lucide-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 { getAccessToken, getFileUrl } from '@/lib/api';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent'; import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area'; 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({ export function MessageList({
messages, messages,
isThinking, isThinking,
messagesEndRef, messagesEndRef,
viewportRef, viewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: { }: {
messages: ChatMessage[]; messages: ChatMessage[];
isThinking: boolean; isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>; messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: 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 ( return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}> <ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-4xl mx-auto py-4 space-y-4"> <div className="max-w-6xl mx-auto py-4 space-y-4">
{messages.length === 0 && !isThinking && ( {messages.length === 0 && teamGroups.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" /> <Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">Boardware Agent Sandbox</p> <p className="text-lg font-medium">Boardware Agent Sandbox</p>
@ -130,9 +230,22 @@ export function MessageList({
</div> </div>
)} )}
{messages.map((msg, i) => ( {timelineItems.map((item) =>
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} /> 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 && ( {isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1"> <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, OutlookOverview,
OutlookStatus, OutlookStatus,
UiAgentDescriptor, UiAgentDescriptor,
UiSubagentDescriptor,
UiMcpServerDescriptor, UiMcpServerDescriptor,
WsEvent, WsEvent,
} from '@/types'; } from '@/types';
@ -631,6 +632,59 @@ export async function refreshAgents(): Promise<{ agents: UiAgentDescriptor[] }>
return fetchJSON('/api/agents/refresh', { method: 'POST' }); 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 }> { export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, { return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
method: 'POST', 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' ? 'run_started'
: event.type === 'process_run_progress' : event.type === 'process_run_progress'
? 'run_progress' ? 'run_progress'
: event.type === 'process_run_message'
? 'run_message'
: event.type === 'process_run_status' : event.type === 'process_run_status'
? 'run_status' ? 'run_status'
: event.type === 'process_run_artifact' : event.type === 'process_run_artifact'
@ -207,6 +209,7 @@ export const useChatStore = create<ChatStore>((set) => ({
? event.summary ? event.summary
: undefined, : undefined,
status: 'status' in event ? event.status : 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, metadata: 'metadata' in event ? event.metadata : undefined,
created_at: event.created_at, created_at: event.created_at,
}); });
@ -225,7 +228,6 @@ export const useChatStore = create<ChatStore>((set) => ({
started_at: event.created_at, started_at: event.created_at,
metadata: event.metadata, metadata: event.metadata,
}); });
nextSelectedRunId = event.run_id;
} }
if (event.type === 'process_run_status') { 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') { if (event.type === 'process_run_artifact') {
nextArtifacts = upsertArtifact(nextArtifacts, { nextArtifacts = upsertArtifact(nextArtifacts, {
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`, artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
@ -273,7 +289,6 @@ export const useChatStore = create<ChatStore>((set) => ({
metadata: event.metadata, metadata: event.metadata,
created_at: event.created_at, created_at: event.created_at,
}); });
nextSelectedRunId = event.run_id;
} }
if (event.type === 'process_run_finished') { if (event.type === 'process_run_finished') {

View File

@ -6,6 +6,14 @@ const nextConfig = {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
images: { unoptimized: true }, images: { unoptimized: true },
webpack: (config) => {
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
phaser3spectorjs: false,
};
return config;
},
}; };
module.exports = nextConfig; 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", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit --incremental false",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
@ -66,6 +67,7 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pdfmake": "^0.2.20", "pdfmake": "^0.2.20",
"pdfmake-with-chinese-fonts": "^1.0.16", "pdfmake-with-chinese-fonts": "^1.0.16",
"phaser": "^3.90.0",
"postcss": "8.4.30", "postcss": "8.4.30",
"react": "18.2.0", "react": "18.2.0",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
@ -88,6 +90,8 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "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

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