```
feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
@ -171,6 +171,8 @@ class AgentRegistry:
|
||||
skills: SkillsLoader | None = None,
|
||||
allow_skill_cards: bool = True,
|
||||
allow_workspace_agents: bool = True,
|
||||
include_local_fallback: bool = True,
|
||||
include_plugin_agents: bool = True,
|
||||
):
|
||||
self.workspace = workspace
|
||||
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
|
||||
@ -178,10 +180,14 @@ class AgentRegistry:
|
||||
self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs())
|
||||
self.allow_skill_cards = allow_skill_cards
|
||||
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)
|
||||
|
||||
def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]:
|
||||
def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]:
|
||||
"""按统一格式列出当前可见 agent。"""
|
||||
if include_local_fallback is None:
|
||||
include_local_fallback = self.include_local_fallback
|
||||
agents: list[AgentDescriptor] = []
|
||||
|
||||
if self.allow_workspace_agents:
|
||||
@ -193,23 +199,24 @@ class AgentRegistry:
|
||||
agents.append(agent)
|
||||
|
||||
# plugin agents 本质上是“带独立系统提示词的本地执行器”。
|
||||
for plugin in self.plugins.plugins.values():
|
||||
for agent in plugin.agents.values():
|
||||
agents.append(
|
||||
AgentDescriptor(
|
||||
id=f"plugin:{agent.name}",
|
||||
name=agent.name,
|
||||
description=agent.description or agent.name,
|
||||
source="plugin",
|
||||
kind="local_prompt",
|
||||
protocol=None,
|
||||
plugin_name=agent.plugin_name,
|
||||
model=agent.model,
|
||||
system_prompt=agent.system_prompt,
|
||||
aliases=[agent.name],
|
||||
metadata={"plugin_name": agent.plugin_name},
|
||||
if self.include_plugin_agents:
|
||||
for plugin in self.plugins.plugins.values():
|
||||
for agent in plugin.agents.values():
|
||||
agents.append(
|
||||
AgentDescriptor(
|
||||
id=f"plugin:{agent.name}",
|
||||
name=agent.name,
|
||||
description=agent.description or agent.name,
|
||||
source="plugin",
|
||||
kind="local_prompt",
|
||||
protocol=None,
|
||||
plugin_name=agent.plugin_name,
|
||||
model=agent.model,
|
||||
system_prompt=agent.system_prompt,
|
||||
aliases=[agent.name],
|
||||
metadata={"plugin_name": agent.plugin_name},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if self.allow_skill_cards:
|
||||
# skill 里声明的 card 视为远端 A2A agent 的静态入口。
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
@ -68,6 +69,9 @@ class DelegationManager:
|
||||
allowed_hosts: list[str] | None = None,
|
||||
authz_config: 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.workspace = workspace
|
||||
@ -76,6 +80,9 @@ class DelegationManager:
|
||||
# local_executor 只负责“本地执行”,不再承担队列编排职责。
|
||||
self.local_executor = local_executor
|
||||
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 只处理远端协议细节,委派策略和公告统一放在本类。
|
||||
self.a2a_client = A2AClient(
|
||||
timeout_seconds=timeout_seconds,
|
||||
@ -88,6 +95,19 @@ class DelegationManager:
|
||||
self._running_tasks: dict[str, DelegationRun] = {}
|
||||
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(
|
||||
self,
|
||||
callback: DirectAnnouncementCallback | None,
|
||||
@ -160,6 +180,7 @@ class DelegationManager:
|
||||
label: str,
|
||||
*,
|
||||
parent_run_id: str | None = None,
|
||||
task: str | None = None,
|
||||
) -> None:
|
||||
# 单 agent 执行开始事件,供前端画执行树。
|
||||
await emit_process_event(
|
||||
@ -177,8 +198,21 @@ class DelegationManager:
|
||||
"protocol": descriptor.protocol,
|
||||
"support_group": descriptor.support_group,
|
||||
"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(
|
||||
self,
|
||||
@ -386,7 +420,7 @@ class DelegationManager:
|
||||
|
||||
# 单 agent 场景先解析目标,再执行。
|
||||
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(
|
||||
origin,
|
||||
descriptor,
|
||||
@ -468,15 +502,20 @@ class DelegationManager:
|
||||
descriptor = self.registry.get_agent(target)
|
||||
if descriptor is None:
|
||||
raise ValueError(f"Agent '{target}' not found")
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
return descriptor
|
||||
|
||||
if strategy == "local":
|
||||
if not self.allow_local_fallback:
|
||||
raise ValueError("Local fallback delegation is disabled")
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor is None:
|
||||
raise ValueError("Local subagent is not available")
|
||||
return descriptor
|
||||
|
||||
if strategy == "plugin":
|
||||
if not self.allow_plugin_delegation:
|
||||
raise ValueError("Plugin delegation is disabled")
|
||||
suggestions = [
|
||||
agent for agent in self.registry.suggest_agents(task)
|
||||
if agent.kind == "local_prompt" and agent.source == "plugin"
|
||||
@ -494,15 +533,46 @@ class DelegationManager:
|
||||
return suggestions[0]
|
||||
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:
|
||||
return suggestions[0]
|
||||
# 自动路由一个都猜不到时,最后回到本地兜底 agent。
|
||||
if not self.allow_local_fallback:
|
||||
raise ValueError("No allowed agent found for delegation")
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor is None:
|
||||
raise ValueError("Local fallback agent is not available")
|
||||
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(
|
||||
self,
|
||||
task: str,
|
||||
@ -520,7 +590,10 @@ class DelegationManager:
|
||||
resolved_targets.append(target)
|
||||
if not resolved_targets:
|
||||
# 未显式给出目标时,根据任务文本自动挑若干个候选 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]
|
||||
if not resolved_targets:
|
||||
raise ValueError("No agents available for group delegation")
|
||||
@ -533,6 +606,7 @@ class DelegationManager:
|
||||
if descriptor is None:
|
||||
missing.append(item)
|
||||
else:
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
descriptors.append(descriptor)
|
||||
if missing:
|
||||
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
||||
@ -544,7 +618,13 @@ class DelegationManager:
|
||||
child_run_id = new_run_id("agent")
|
||||
async with semaphore:
|
||||
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(
|
||||
descriptor,
|
||||
task,
|
||||
@ -588,6 +668,12 @@ class DelegationManager:
|
||||
"""根据 descriptor 类型执行具体 agent。"""
|
||||
logger.info("Delegating '{}' to {}", label, descriptor.id)
|
||||
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 事件挂父节点。
|
||||
with process_run_context(process_run_id):
|
||||
return await self.local_executor.run_local_task(
|
||||
@ -611,6 +697,19 @@ class DelegationManager:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
origin: dict[str, str],
|
||||
|
||||
@ -76,6 +76,13 @@ class AgentLoop:
|
||||
channels_config: ChannelsConfig | None = None,
|
||||
authz_config: 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
|
||||
# 基础依赖与运行参数。
|
||||
@ -95,6 +102,13 @@ class AgentLoop:
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.authz_config = authz_config
|
||||
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)
|
||||
@ -106,6 +120,8 @@ class AgentLoop:
|
||||
skills=self.skills,
|
||||
allow_skill_cards=self.a2a_config.allow_skill_cards,
|
||||
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(
|
||||
workspace,
|
||||
@ -137,6 +153,9 @@ class AgentLoop:
|
||||
allowed_hosts=self.a2a_config.allowed_hosts,
|
||||
authz_config=self.authz_config,
|
||||
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(WebFetchTool())
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
self.tools.register(SpawnTool(manager=self.delegation))
|
||||
if self.allow_message:
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
if self.allow_spawn:
|
||||
self.tools.register(SpawnTool(manager=self.delegation))
|
||||
|
||||
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
|
||||
if self.cron_service:
|
||||
if self.cron_service and self.allow_cron:
|
||||
self.tools.register(CronTool(self.cron_service))
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
|
||||
@ -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}
|
||||
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}"""
|
||||
|
||||
259
app-instance/backend/nanobot/agent/subagents.py
Normal 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.
|
||||
"""
|
||||
@ -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`.
|
||||
@ -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()
|
||||
@ -11,6 +11,7 @@ import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@ -436,6 +437,21 @@ class MCPServerRequest(BaseModel):
|
||||
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):
|
||||
email: str
|
||||
password: str
|
||||
@ -733,6 +749,7 @@ def create_app(
|
||||
app.state.auth_tokens: dict[str, str] = {}
|
||||
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
||||
app.state.auth_file = _get_auth_file_path()
|
||||
app.state.subagent_tasks: dict[str, dict[str, Any]] = {}
|
||||
|
||||
_register_routes(app)
|
||||
return app
|
||||
@ -1083,6 +1100,157 @@ def _register_routes(app: FastAPI) -> None:
|
||||
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:
|
||||
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
|
||||
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]:
|
||||
return {
|
||||
"backend_id": config.backend_identity.backend_id,
|
||||
@ -2473,6 +2733,55 @@ def _register_routes(app: FastAPI) -> None:
|
||||
})
|
||||
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")
|
||||
async def list_agents():
|
||||
"""List unified agents from workspace, plugins, skills, and local fallback."""
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Tags,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
|
||||
import {
|
||||
addAgent,
|
||||
createSubagent,
|
||||
deleteAgent,
|
||||
deleteSubagent,
|
||||
listAgents,
|
||||
listSubagents,
|
||||
refreshAgents,
|
||||
updateSubagent,
|
||||
} from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { UiAgentDescriptor } from '@/types';
|
||||
import type { UiAgentDescriptor, UiSubagentDescriptor } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -13,9 +32,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
const EMPTY_AGENT_FORM = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
@ -30,17 +51,69 @@ const EMPTY_FORM = {
|
||||
aliases: '',
|
||||
};
|
||||
|
||||
const EMPTY_SUBAGENT_FORM = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
system_prompt: '',
|
||||
model: '',
|
||||
delegation_mode: 'remote_a2a_only',
|
||||
enabled: true,
|
||||
allow_mcp: true,
|
||||
tags: '',
|
||||
aliases: '',
|
||||
metadata_json: '{}',
|
||||
mcp_servers_json: '{}',
|
||||
};
|
||||
|
||||
function formatJson(value: Record<string, unknown>): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string, label: string): Record<string, unknown> {
|
||||
const probe = raw.trim();
|
||||
if (!probe) {
|
||||
return {};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(probe);
|
||||
} catch {
|
||||
throw new Error(`${label} 需要是合法 JSON`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 需要是 JSON 对象`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> {
|
||||
const parsed = parseJsonObject(raw, label);
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`);
|
||||
}
|
||||
}
|
||||
return parsed as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const cachedAgents = useChatStore((s) => s.agentRegistry);
|
||||
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
|
||||
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
|
||||
const [subagents, setSubagents] = useState<UiSubagentDescriptor[]>([]);
|
||||
const [loading, setLoading] = useState(cachedAgents.length === 0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||
const [subagentDialogOpen, setSubagentDialogOpen] = useState(false);
|
||||
const [agentSubmitting, setAgentSubmitting] = useState(false);
|
||||
const [subagentSubmitting, setSubagentSubmitting] = useState(false);
|
||||
const [agentAdvancedOpen, setAgentAdvancedOpen] = useState(false);
|
||||
const [subagentAdvancedOpen, setSubagentAdvancedOpen] = useState(false);
|
||||
const [agentForm, setAgentForm] = useState(EMPTY_AGENT_FORM);
|
||||
const [subagentForm, setSubagentForm] = useState(EMPTY_SUBAGENT_FORM);
|
||||
const [editingSubagentId, setEditingSubagentId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (background = false) => {
|
||||
if (background) {
|
||||
@ -50,9 +123,14 @@ export default function AgentsPage() {
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listAgents();
|
||||
const nextAgents = Array.isArray(data) ? data : [];
|
||||
const [agentData, subagentData] = await Promise.all([
|
||||
listAgents(),
|
||||
listSubagents(),
|
||||
]);
|
||||
const nextAgents = Array.isArray(agentData) ? agentData : [];
|
||||
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
|
||||
setAgents(nextAgents);
|
||||
setSubagents(nextSubagents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载智能体失败');
|
||||
@ -73,9 +151,14 @@ export default function AgentsPage() {
|
||||
setError(null);
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await refreshAgents();
|
||||
const nextAgents = data.agents || [];
|
||||
const [agentData, subagentData] = await Promise.all([
|
||||
refreshAgents(),
|
||||
listSubagents(),
|
||||
]);
|
||||
const nextAgents = agentData.agents || [];
|
||||
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
|
||||
setAgents(nextAgents);
|
||||
setSubagents(nextSubagents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '刷新智能体失败');
|
||||
@ -84,59 +167,133 @@ export default function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
const handleAgentDialogOpenChange = (open: boolean) => {
|
||||
setAgentDialogOpen(open);
|
||||
if (!open) {
|
||||
setAdvancedOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
setAgentAdvancedOpen(false);
|
||||
setAgentForm(EMPTY_AGENT_FORM);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
const handleSubagentDialogOpenChange = (open: boolean) => {
|
||||
setSubagentDialogOpen(open);
|
||||
if (!open) {
|
||||
setSubagentAdvancedOpen(false);
|
||||
setEditingSubagentId(null);
|
||||
setSubagentForm(EMPTY_SUBAGENT_FORM);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAgent = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
|
||||
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim());
|
||||
if (!hasAddress) {
|
||||
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setAgentSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addAgent({
|
||||
id: form.id || undefined,
|
||||
name: form.name || undefined,
|
||||
description: form.description || undefined,
|
||||
id: agentForm.id || undefined,
|
||||
name: agentForm.name || undefined,
|
||||
description: agentForm.description || undefined,
|
||||
protocol: 'a2a',
|
||||
base_url: form.base_url || undefined,
|
||||
endpoint: form.endpoint || undefined,
|
||||
card_url: form.card_url || undefined,
|
||||
auth_env: form.auth_env || undefined,
|
||||
auth_mode: form.auth_mode || 'none',
|
||||
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
|
||||
auth_scopes: form.auth_mode === 'none'
|
||||
base_url: agentForm.base_url || undefined,
|
||||
endpoint: agentForm.endpoint || undefined,
|
||||
card_url: agentForm.card_url || undefined,
|
||||
auth_env: agentForm.auth_env || undefined,
|
||||
auth_mode: agentForm.auth_mode || 'none',
|
||||
auth_audience: agentForm.auth_mode === 'none' ? undefined : agentForm.auth_audience || undefined,
|
||||
auth_scopes: agentForm.auth_mode === 'none'
|
||||
? []
|
||||
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
: agentForm.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
tags: agentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
aliases: agentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
});
|
||||
handleDialogOpenChange(false);
|
||||
await load();
|
||||
handleAgentDialogOpenChange(false);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '新增智能体失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setAgentSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string) => {
|
||||
const handleDeleteAgent = async (agentId: string) => {
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
await load();
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除智能体失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSubagent = (subagent: UiSubagentDescriptor) => {
|
||||
setEditingSubagentId(subagent.id);
|
||||
setSubagentForm({
|
||||
id: subagent.id,
|
||||
name: subagent.name,
|
||||
description: subagent.description,
|
||||
system_prompt: subagent.system_prompt || '',
|
||||
model: subagent.model || '',
|
||||
delegation_mode: subagent.delegation_mode || 'remote_a2a_only',
|
||||
enabled: subagent.enabled,
|
||||
allow_mcp: subagent.allow_mcp,
|
||||
tags: (subagent.tags || []).join(', '),
|
||||
aliases: (subagent.aliases || []).join(', '),
|
||||
metadata_json: formatJson(subagent.metadata || {}),
|
||||
mcp_servers_json: formatJson(subagent.mcp_servers || {}),
|
||||
});
|
||||
setSubagentDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveSubagent = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!subagentForm.id.trim()) {
|
||||
setError('Sub-agent ID 不能为空');
|
||||
return;
|
||||
}
|
||||
setSubagentSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
id: subagentForm.id.trim(),
|
||||
name: subagentForm.name.trim() || subagentForm.id.trim(),
|
||||
description: subagentForm.description.trim() || subagentForm.name.trim() || subagentForm.id.trim(),
|
||||
system_prompt: subagentForm.system_prompt,
|
||||
model: subagentForm.model.trim() || undefined,
|
||||
enabled: subagentForm.enabled,
|
||||
delegation_mode: subagentForm.delegation_mode,
|
||||
allow_mcp: subagentForm.allow_mcp,
|
||||
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'),
|
||||
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'),
|
||||
};
|
||||
if (editingSubagentId) {
|
||||
await updateSubagent(editingSubagentId, payload);
|
||||
} else {
|
||||
await createSubagent(payload);
|
||||
}
|
||||
handleSubagentDialogOpenChange(false);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存 Sub-Agent 失败');
|
||||
} finally {
|
||||
setSubagentSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteManagedSubagent = async (subagentId: string) => {
|
||||
try {
|
||||
await deleteSubagent(subagentId);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除 Sub-Agent 失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@ -154,17 +311,17 @@ export default function AgentsPage() {
|
||||
智能体
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理工作区智能体,并查看来自插件、技能和内置能力的可委派目标。
|
||||
管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增智能体
|
||||
</Button>
|
||||
@ -173,53 +330,51 @@ export default function AgentsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增工作区智能体</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleCreate}>
|
||||
<form className="space-y-4" onSubmit={handleCreateAgent}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base_url">A2A 部署地址</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
|
||||
value={agentForm.base_url}
|
||||
onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))}
|
||||
placeholder="https://agent.example.com 或 agent.example.com:19090"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
默认只需要填写部署地址。保存时会自动读取
|
||||
<code className="mx-1">/.well-known/agent-card</code>
|
||||
、<code className="mx-1">/.well-known/agent-card.json</code>
|
||||
和<code className="mx-1">/.well-known/agent.json</code>
|
||||
,并补齐 ID、名称、描述、接口地址等信息。
|
||||
<code className="mx-1">/.well-known</code>
|
||||
路径并补齐 card 信息。
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="outline" className="w-full justify-between">
|
||||
高级设置(可选)
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="id">ID</Label>
|
||||
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" />
|
||||
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
|
||||
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
|
||||
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint">接口地址</Label>
|
||||
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
|
||||
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card_url">卡片地址</Label>
|
||||
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
|
||||
<Input id="card_url" value={agentForm.card_url} onChange={(e) => setAgentForm((s) => ({ ...s, card_url: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
@ -227,8 +382,8 @@ export default function AgentsPage() {
|
||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||
<select
|
||||
id="auth_mode"
|
||||
value={form.auth_mode}
|
||||
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
||||
value={agentForm.auth_mode}
|
||||
onChange={(e) => setAgentForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="none">none</option>
|
||||
@ -237,46 +392,211 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_audience">Audience</Label>
|
||||
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
|
||||
<Input id="auth_audience" value={agentForm.auth_audience} onChange={(e) => setAgentForm((s) => ({ ...s, auth_audience: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_scopes">Scopes</Label>
|
||||
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
|
||||
<Input id="auth_scopes" value={agentForm.auth_scopes} onChange={(e) => setAgentForm((s) => ({ ...s, auth_scopes: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_env">认证环境变量</Label>
|
||||
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如:MY_AGENT_TOKEN" />
|
||||
<Input id="auth_env" value={agentForm.auth_env} onChange={(e) => setAgentForm((s) => ({ ...s, auth_env: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
|
||||
<Input id="tags" value={agentForm.tags} onChange={(e) => setAgentForm((s) => ({ ...s, tags: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="aliases">别名</Label>
|
||||
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如:reviewer, audit-agent" />
|
||||
<Input id="aliases" value={agentForm.aliases} onChange={(e) => setAgentForm((s) => ({ ...s, aliases: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
如果远端 card 需要额外鉴权信息,或者服务没有暴露标准
|
||||
<code className="mx-1">.well-known</code>
|
||||
路径,再展开高级设置手动补充。
|
||||
如果这是持久化本地 Sub-Agent,请改用下面的 Sub-Agent 面板,不要在这里单独删除 registry 记录。
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
|
||||
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
<Button type="submit" disabled={agentSubmitting}>
|
||||
{agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={subagentDialogOpen} onOpenChange={handleSubagentDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增 Sub-Agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSubagentId ? '编辑 Sub-Agent' : '新增 Persistent Sub-Agent'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSaveSubagent}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_id">ID</Label>
|
||||
<Input
|
||||
id="subagent_id"
|
||||
value={subagentForm.id}
|
||||
disabled={Boolean(editingSubagentId)}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, id: e.target.value }))}
|
||||
placeholder="research-agent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_name">名称</Label>
|
||||
<Input
|
||||
id="subagent_name"
|
||||
value={subagentForm.name}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, name: e.target.value }))}
|
||||
placeholder="Research Agent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_description">描述</Label>
|
||||
<Textarea
|
||||
id="subagent_description"
|
||||
rows={3}
|
||||
value={subagentForm.description}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
|
||||
placeholder="用于研究和资料整理的本地持久化子智能体"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_prompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="subagent_prompt"
|
||||
rows={6}
|
||||
value={subagentForm.system_prompt}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, system_prompt: e.target.value }))}
|
||||
placeholder="Focus on research tasks, be concise, and cite concrete findings."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_model">模型</Label>
|
||||
<Input
|
||||
id="subagent_model"
|
||||
value={subagentForm.model}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
|
||||
placeholder="留空则继承主 Agent 默认模型"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delegation_mode">委派模式</Label>
|
||||
<select
|
||||
id="delegation_mode"
|
||||
value={subagentForm.delegation_mode}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, delegation_mode: e.target.value }))}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="remote_a2a_only">remote_a2a_only</option>
|
||||
<option value="full">full</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="subagent_enabled">启用</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">关闭后仍保留 workspace 和配置</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="subagent_enabled"
|
||||
checked={subagentForm.enabled}
|
||||
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="subagent_allow_mcp">允许 MCP</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">保留 MCP 配置并在运行时接入</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="subagent_allow_mcp"
|
||||
checked={subagentForm.allow_mcp}
|
||||
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, allow_mcp: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_tags">标签</Label>
|
||||
<Input
|
||||
id="subagent_tags"
|
||||
value={subagentForm.tags}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, tags: e.target.value }))}
|
||||
placeholder="research, notes"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_aliases">别名</Label>
|
||||
<Input
|
||||
id="subagent_aliases"
|
||||
value={subagentForm.aliases}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, aliases: e.target.value }))}
|
||||
placeholder="researcher, local-research"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="outline" className="w-full justify-between">
|
||||
原始 JSON 设置(Metadata / MCP)
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_metadata_json">Metadata JSON</Label>
|
||||
<Textarea
|
||||
id="subagent_metadata_json"
|
||||
rows={6}
|
||||
value={subagentForm.metadata_json}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, metadata_json: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_mcp_json">MCP Servers JSON</Label>
|
||||
<Textarea
|
||||
id="subagent_mcp_json"
|
||||
rows={8}
|
||||
value={subagentForm.mcp_servers_json}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, mcp_servers_json: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
创建后会自动生成独立 workspace、写入
|
||||
<code className="mx-1">AGENTS.json</code>
|
||||
和
|
||||
<code className="mx-1">AGENTS.md</code>
|
||||
,并注册到工作区智能体列表。
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={subagentSubmitting}>
|
||||
{subagentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : editingSubagentId ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{editingSubagentId ? '更新' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -291,73 +611,178 @@ export default function AgentsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{agents.map((agent) => {
|
||||
const isWorkspace = agent.source === 'workspace';
|
||||
return (
|
||||
<Card key={agent.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{agent.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
||||
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
||||
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
||||
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
||||
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
||||
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
||||
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
||||
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
||||
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience:</span> {agent.auth_audience}</div>}
|
||||
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
||||
</div>
|
||||
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
{agent.tags.length > 0 && (
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||
{agent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{agent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
{agent.aliases.map((alias) => (
|
||||
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||
))}
|
||||
<Tabs defaultValue="agents" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="agents">委派目标</TabsTrigger>
|
||||
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="agents" className="space-y-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{agents.map((agent) => {
|
||||
const isWorkspace = agent.source === 'workspace';
|
||||
const isManagedSubagent = Boolean(agent.metadata && agent.metadata.local_subagent);
|
||||
return (
|
||||
<Card key={agent.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{agent.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
||||
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
||||
{isManagedSubagent && <Badge className="bg-amber-600">受管 Sub-Agent</Badge>}
|
||||
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
||||
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
||||
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
||||
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
||||
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
||||
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
||||
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience:</span> {agent.auth_audience}</div>}
|
||||
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
||||
</div>
|
||||
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
{agent.tags.length > 0 && (
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||
{agent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{agent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
{agent.aliases.map((alias) => (
|
||||
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
{isManagedSubagent ? (
|
||||
<span className="text-xs text-muted-foreground">请在 Sub-Agent 面板管理</span>
|
||||
) : isWorkspace ? (
|
||||
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">只读来源</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subagents" className="space-y-4">
|
||||
<Card className="border-border/70 bg-muted/20">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
|
||||
持久化 Sub-Agent 会在
|
||||
<code className="mx-1">~/.nanobot/workspace/agents/<id>_agent</code>
|
||||
下拥有自己的 workspace、`AGENTS.json`、`AGENTS.md`、skills 和 memory。
|
||||
默认委派模式是
|
||||
<code className="mx-1">remote_a2a_only</code>
|
||||
,即只能向外委派到远端 A2A agent。
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{subagents.map((subagent) => (
|
||||
<Card key={subagent.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">{subagent.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{subagent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{subagent.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant={subagent.enabled ? 'default' : 'outline'}>
|
||||
{subagent.enabled ? '启用' : '停用'}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{subagent.delegation_mode}</Badge>
|
||||
{subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>}
|
||||
{subagent.model && <Badge variant="outline">{subagent.model}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
{isWorkspace ? (
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
<div><span className="font-medium text-foreground">Workspace:</span> {subagent.workspace}</div>
|
||||
<div><span className="font-medium text-foreground">Base URL:</span> {subagent.base_url}</div>
|
||||
<div><span className="font-medium text-foreground">RPC:</span> {subagent.endpoint}</div>
|
||||
<div><span className="font-medium text-foreground">Card:</span> {subagent.card_url}</div>
|
||||
<div><span className="font-medium text-foreground">MCP Servers:</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
|
||||
</div>
|
||||
{subagent.system_prompt && (
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground mb-1">System Prompt</div>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-5">
|
||||
{subagent.system_prompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(subagent.tags.length > 0 || subagent.aliases.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
{subagent.tags.length > 0 && (
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||
{subagent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{subagent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
{subagent.aliases.map((alias) => (
|
||||
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">只读来源</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{subagents.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有持久化 Sub-Agent。点击右上角“新增 Sub-Agent”开始创建。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -132,6 +133,8 @@ export default function CronPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
<TaskManagementTabs />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
|
||||
532
app-instance/frontend/app/(app)/office/[taskId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
app-instance/frontend/app/(app)/office/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@ -19,6 +21,7 @@ import {
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||||
|
||||
@ -133,6 +136,19 @@ export default function ChatPage() {
|
||||
);
|
||||
}, [commands, input]);
|
||||
|
||||
const officeTasks = useMemo(
|
||||
() => buildOfficeTaskList({
|
||||
sessionId,
|
||||
sessions,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
}),
|
||||
[processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
);
|
||||
|
||||
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listSessions();
|
||||
@ -544,6 +560,37 @@ export default function ChatPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{currentOfficeTask ? (
|
||||
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Building2 className="h-4 w-4" />
|
||||
当前任务现场
|
||||
</div>
|
||||
<OfficeStatusBadge status={currentOfficeTask.status} />
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{currentOfficeTask.title}
|
||||
<span className="ml-2">主 Agent: {currentOfficeTask.rootActorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/office">查看全部 Office</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
|
||||
查看任务现场
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatWorkbench
|
||||
messages={messages}
|
||||
@ -554,7 +601,7 @@ export default function ChatPage() {
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={setSelectedRunId}
|
||||
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
|
||||
onCancelRun={handleCancelRun}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -8,10 +8,17 @@ import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen,
|
||||
import { logout } from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
type NavItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ name: '对话', href: '/', icon: MessageSquare },
|
||||
{ name: '状态', href: '/status', icon: Activity },
|
||||
{ name: '定时任务', href: '/cron', icon: Clock },
|
||||
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||
{ name: '技能', href: '/skills', icon: Puzzle },
|
||||
{ name: '插件', href: '/plugins', icon: Blocks },
|
||||
{ name: '智能体', href: '/agents', icon: Bot },
|
||||
@ -97,7 +104,7 @@ const Header = () => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href);
|
||||
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,6 @@ import React from 'react';
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
|
||||
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
||||
|
||||
export function ChatWorkbench({
|
||||
@ -31,14 +30,15 @@ export function ChatWorkbench({
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const selectedRun = processRuns.find((item) => item.run_id === selectedRunId) || processRuns[0] || null;
|
||||
const selectedRun = selectedRunId
|
||||
? processRuns.find((item) => item.run_id === selectedRunId) || null
|
||||
: null;
|
||||
const selectedRunEvents = selectedRun
|
||||
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const selectedRunArtifacts = selectedRun
|
||||
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const hasProcessLane = processRuns.length > 0;
|
||||
const hasResultsPanel = Boolean(
|
||||
selectedRun &&
|
||||
(
|
||||
@ -47,13 +47,9 @@ export function ChatWorkbench({
|
||||
selectedRunArtifacts.length > 0
|
||||
)
|
||||
);
|
||||
const desktopColumns = hasProcessLane && hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]'
|
||||
: hasProcessLane
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||
const desktopColumns = hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -64,19 +60,14 @@ export function ChatWorkbench({
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</div>
|
||||
{hasProcessLane && (
|
||||
<div className="min-h-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResultsPanel && (
|
||||
<div className="min-h-0">
|
||||
<ArtifactSidebar
|
||||
@ -89,25 +80,24 @@ export function ChatWorkbench({
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden h-full">
|
||||
{!hasProcessLane && !hasResultsPanel ? (
|
||||
{!hasResultsPanel ? (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-3 border-b border-border">
|
||||
<TabsList
|
||||
className={`grid w-full ${
|
||||
hasProcessLane && hasResultsPanel
|
||||
? 'grid-cols-3'
|
||||
: 'grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">聊天</TabsTrigger>
|
||||
{hasProcessLane && <TabsTrigger value="process">过程</TabsTrigger>}
|
||||
{hasResultsPanel && <TabsTrigger value="results">结果</TabsTrigger>}
|
||||
</TabsList>
|
||||
</div>
|
||||
@ -117,19 +107,14 @@ export function ChatWorkbench({
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</TabsContent>
|
||||
{hasProcessLane && (
|
||||
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{hasResultsPanel && (
|
||||
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
||||
<ArtifactSidebar
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
import React from 'react';
|
||||
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage } from '@/types';
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
@ -108,21 +109,120 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AgentTeamGroup = {
|
||||
rootRun: ProcessRun;
|
||||
memberRuns: ProcessRun[];
|
||||
startedAt: string;
|
||||
};
|
||||
|
||||
function parseTimelineTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
|
||||
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
|
||||
const groups = new Map<string, AgentTeamGroup>();
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (run.actor_type !== 'agent') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let root = run;
|
||||
const seen = new Set<string>([run.run_id]);
|
||||
let parentId = run.parent_run_id ?? null;
|
||||
while (parentId) {
|
||||
const parent = runMap.get(parentId);
|
||||
if (!parent || seen.has(parent.run_id)) {
|
||||
break;
|
||||
}
|
||||
root = parent;
|
||||
seen.add(parent.run_id);
|
||||
parentId = parent.parent_run_id ?? null;
|
||||
}
|
||||
|
||||
const existing = groups.get(root.run_id);
|
||||
if (existing) {
|
||||
existing.memberRuns.push(run);
|
||||
continue;
|
||||
}
|
||||
groups.set(root.run_id, {
|
||||
rootRun: root,
|
||||
memberRuns: [run],
|
||||
startedAt: root.started_at || run.started_at,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
|
||||
const at = parseTimelineTime(a.started_at) ?? 0;
|
||||
const bt = parseTimelineTime(b.started_at) ?? 0;
|
||||
return at - bt;
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const at = parseTimelineTime(a.startedAt) ?? 0;
|
||||
const bt = parseTimelineTime(b.startedAt) ?? 0;
|
||||
return at - bt;
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isThinking,
|
||||
messagesEndRef,
|
||||
viewportRef,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isThinking: boolean;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
viewportRef: React.RefObject<HTMLDivElement>;
|
||||
processRuns: ProcessRun[];
|
||||
processEvents: ProcessEvent[];
|
||||
processArtifacts: ProcessArtifact[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const teamGroups = React.useMemo(() => buildAgentTeamGroups(processRuns), [processRuns]);
|
||||
const timelineItems = React.useMemo(() => {
|
||||
const messageItems = messages.map((message, index) => ({
|
||||
kind: 'message' as const,
|
||||
key: `${message.role}:${message.timestamp || index}:${index}`,
|
||||
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
||||
order: index,
|
||||
message,
|
||||
}));
|
||||
const teamItems = teamGroups.map((group, index) => ({
|
||||
kind: 'team' as const,
|
||||
key: `team:${group.rootRun.run_id}`,
|
||||
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index,
|
||||
order: messages.length + index,
|
||||
group,
|
||||
}));
|
||||
|
||||
return [...messageItems, ...teamItems].sort((a, b) => {
|
||||
if (a.sortTime !== b.sortTime) {
|
||||
return a.sortTime - b.sortTime;
|
||||
}
|
||||
return a.order - b.order;
|
||||
});
|
||||
}, [messages, teamGroups]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||
<div className="max-w-4xl mx-auto py-4 space-y-4">
|
||||
{messages.length === 0 && !isThinking && (
|
||||
<div className="max-w-6xl mx-auto py-4 space-y-4">
|
||||
{messages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
||||
@ -130,9 +230,22 @@ export function MessageList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
|
||||
))}
|
||||
{timelineItems.map((item) =>
|
||||
item.kind === 'message' ? (
|
||||
<MessageBubble key={item.key} message={item.message} />
|
||||
) : (
|
||||
<AgentTeamBlock
|
||||
key={item.key}
|
||||
rootRun={item.group.rootRun}
|
||||
memberRuns={item.group.memberRuns}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||
|
||||
488
app-instance/frontend/components/office/OfficePhaserCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
app-instance/frontend/components/office/OfficeShared.tsx
Normal 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'
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,7 @@ import type {
|
||||
OutlookOverview,
|
||||
OutlookStatus,
|
||||
UiAgentDescriptor,
|
||||
UiSubagentDescriptor,
|
||||
UiMcpServerDescriptor,
|
||||
WsEvent,
|
||||
} from '@/types';
|
||||
@ -631,6 +632,59 @@ export async function refreshAgents(): Promise<{ agents: UiAgentDescriptor[] }>
|
||||
return fetchJSON('/api/agents/refresh', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function listSubagents(): Promise<UiSubagentDescriptor[]> {
|
||||
return fetchJSON('/api/subagents');
|
||||
}
|
||||
|
||||
export async function createSubagent(payload: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
model?: string;
|
||||
enabled?: boolean;
|
||||
delegation_mode?: string;
|
||||
allow_mcp?: boolean;
|
||||
tags?: string[];
|
||||
aliases?: string[];
|
||||
mcp_servers?: Record<string, Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<UiSubagentDescriptor> {
|
||||
return fetchJSON('/api/subagents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSubagent(
|
||||
subagentId: string,
|
||||
payload: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
model?: string;
|
||||
enabled?: boolean;
|
||||
delegation_mode?: string;
|
||||
allow_mcp?: boolean;
|
||||
tags?: string[];
|
||||
aliases?: string[];
|
||||
mcp_servers?: Record<string, Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<UiSubagentDescriptor> {
|
||||
return fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSubagent(subagentId: string): Promise<void> {
|
||||
await fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
|
||||
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
|
||||
method: 'POST',
|
||||
|
||||
227
app-instance/frontend/lib/office.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
704
app-instance/frontend/lib/office.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -190,6 +190,8 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
? 'run_started'
|
||||
: event.type === 'process_run_progress'
|
||||
? 'run_progress'
|
||||
: event.type === 'process_run_message'
|
||||
? 'run_message'
|
||||
: event.type === 'process_run_status'
|
||||
? 'run_status'
|
||||
: event.type === 'process_run_artifact'
|
||||
@ -207,6 +209,7 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
? event.summary
|
||||
: undefined,
|
||||
status: 'status' in event ? event.status : undefined,
|
||||
message_role: 'message_role' in event ? event.message_role : undefined,
|
||||
metadata: 'metadata' in event ? event.metadata : undefined,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
@ -225,7 +228,6 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
started_at: event.created_at,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
nextSelectedRunId = event.run_id;
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_status') {
|
||||
@ -257,6 +259,20 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_message') {
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
status: current?.status || 'running',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_artifact') {
|
||||
nextArtifacts = upsertArtifact(nextArtifacts, {
|
||||
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
|
||||
@ -273,7 +289,6 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
metadata: event.metadata,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
nextSelectedRunId = event.run_id;
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_finished') {
|
||||
|
||||
@ -6,6 +6,14 @@ const nextConfig = {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: { unoptimized: true },
|
||||
webpack: (config) => {
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias || {}),
|
||||
phaser3spectorjs: false,
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
1818
app-instance/frontend/office-ui.md
Normal file
1705
app-instance/frontend/package-lock.json
generated
@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit --incremental false",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
@ -66,6 +67,7 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"pdfmake": "^0.2.20",
|
||||
"pdfmake-with-chinese-fonts": "^1.0.16",
|
||||
"phaser": "^3.90.0",
|
||||
"postcss": "8.4.30",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@ -88,6 +90,8 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pdfmake": "^0.2.12"
|
||||
"@types/pdfmake": "^0.2.12",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
18
app-instance/frontend/public/office/README.md
Normal 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
|
||||
9
app-instance/frontend/public/office/atlas/README.md
Normal 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`
|
||||
18
app-instance/frontend/public/office/maps/README.md
Normal 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`
|
||||
@ -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
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 19 KiB |
@ -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
|
||||
2498
app-instance/frontend/public/office/maps/office-winter-v1.tmj
Normal file
23
app-instance/frontend/public/office/sprites/agents/README.md
Normal 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
|
||||
@ -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`
|
||||
13
app-instance/frontend/public/office/sprites/status/README.md
Normal 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`
|
||||
21
app-instance/frontend/public/office/tiles/README.md
Normal 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`
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 825 B |
21
app-instance/frontend/public/office/vendor/pixel-agents/LICENSE
vendored
Normal 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.
|
||||
17
app-instance/frontend/public/office/vendor/pixel-agents/README.md
vendored
Normal 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.
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_0.png
vendored
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_1.png
vendored
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_2.png
vendored
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_3.png
vendored
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_4.png
vendored
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_5.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
92
app-instance/frontend/public/office/vendor/pixel-agents/assets/default-layout-1.json
vendored
Normal 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}
|
||||
]
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_0.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_1.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_2.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_3.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_4.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_5.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_6.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_7.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_8.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BIN/BIN.png
vendored
Normal file
|
After Width: | Height: | Size: 252 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BIN/manifest.json
vendored
Normal 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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BOOKSHELF/BOOKSHELF.png
vendored
Normal file
|
After Width: | Height: | Size: 388 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BOOKSHELF/manifest.json
vendored
Normal 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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CACTUS/CACTUS.png
vendored
Normal file
|
After Width: | Height: | Size: 558 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CACTUS/manifest.json
vendored
Normal 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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CLOCK/CLOCK.png
vendored
Normal file
|
After Width: | Height: | Size: 304 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CLOCK/manifest.json
vendored
Normal 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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE/COFFEE.png
vendored
Normal file
|
After Width: | Height: | Size: 223 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE/manifest.json
vendored
Normal 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
|
||||
}
|
||||
|
After Width: | Height: | Size: 274 B |
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 250 B |
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 205 B |
|
After Width: | Height: | Size: 247 B |
|
After Width: | Height: | Size: 255 B |
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/DESK_FRONT.png
vendored
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/DESK_SIDE.png
vendored
Normal file
|
After Width: | Height: | Size: 278 B |
33
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 627 B |
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 693 B |
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@ -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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/LARGE_PLANT/LARGE_PLANT.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
@ -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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_BACK.png
vendored
Normal file
|
After Width: | Height: | Size: 349 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_OFF.png
vendored
Normal file
|
After Width: | Height: | Size: 427 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_1.png
vendored
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_2.png
vendored
Normal file
|
After Width: | Height: | Size: 476 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_3.png
vendored
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_SIDE.png
vendored
Normal file
|
After Width: | Height: | Size: 451 B |
88
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/manifest.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT/PLANT.png
vendored
Normal file
|
After Width: | Height: | Size: 703 B |
13
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT/manifest.json
vendored
Normal 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
|
||||
}
|
||||
BIN
app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT_2/PLANT_2.png
vendored
Normal file
|
After Width: | Height: | Size: 543 B |