```
feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
@ -171,6 +171,8 @@ class AgentRegistry:
|
|||||||
skills: SkillsLoader | None = None,
|
skills: SkillsLoader | None = None,
|
||||||
allow_skill_cards: bool = True,
|
allow_skill_cards: bool = True,
|
||||||
allow_workspace_agents: bool = True,
|
allow_workspace_agents: bool = True,
|
||||||
|
include_local_fallback: bool = True,
|
||||||
|
include_plugin_agents: bool = True,
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
|
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
|
||||||
@ -178,10 +180,14 @@ class AgentRegistry:
|
|||||||
self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs())
|
self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs())
|
||||||
self.allow_skill_cards = allow_skill_cards
|
self.allow_skill_cards = allow_skill_cards
|
||||||
self.allow_workspace_agents = allow_workspace_agents
|
self.allow_workspace_agents = allow_workspace_agents
|
||||||
|
self.include_local_fallback = include_local_fallback
|
||||||
|
self.include_plugin_agents = include_plugin_agents
|
||||||
self.workspace_store = WorkspaceAgentStore(workspace)
|
self.workspace_store = WorkspaceAgentStore(workspace)
|
||||||
|
|
||||||
def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]:
|
def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]:
|
||||||
"""按统一格式列出当前可见 agent。"""
|
"""按统一格式列出当前可见 agent。"""
|
||||||
|
if include_local_fallback is None:
|
||||||
|
include_local_fallback = self.include_local_fallback
|
||||||
agents: list[AgentDescriptor] = []
|
agents: list[AgentDescriptor] = []
|
||||||
|
|
||||||
if self.allow_workspace_agents:
|
if self.allow_workspace_agents:
|
||||||
@ -193,23 +199,24 @@ class AgentRegistry:
|
|||||||
agents.append(agent)
|
agents.append(agent)
|
||||||
|
|
||||||
# plugin agents 本质上是“带独立系统提示词的本地执行器”。
|
# plugin agents 本质上是“带独立系统提示词的本地执行器”。
|
||||||
for plugin in self.plugins.plugins.values():
|
if self.include_plugin_agents:
|
||||||
for agent in plugin.agents.values():
|
for plugin in self.plugins.plugins.values():
|
||||||
agents.append(
|
for agent in plugin.agents.values():
|
||||||
AgentDescriptor(
|
agents.append(
|
||||||
id=f"plugin:{agent.name}",
|
AgentDescriptor(
|
||||||
name=agent.name,
|
id=f"plugin:{agent.name}",
|
||||||
description=agent.description or agent.name,
|
name=agent.name,
|
||||||
source="plugin",
|
description=agent.description or agent.name,
|
||||||
kind="local_prompt",
|
source="plugin",
|
||||||
protocol=None,
|
kind="local_prompt",
|
||||||
plugin_name=agent.plugin_name,
|
protocol=None,
|
||||||
model=agent.model,
|
plugin_name=agent.plugin_name,
|
||||||
system_prompt=agent.system_prompt,
|
model=agent.model,
|
||||||
aliases=[agent.name],
|
system_prompt=agent.system_prompt,
|
||||||
metadata={"plugin_name": agent.plugin_name},
|
aliases=[agent.name],
|
||||||
|
metadata={"plugin_name": agent.plugin_name},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if self.allow_skill_cards:
|
if self.allow_skill_cards:
|
||||||
# skill 里声明的 card 视为远端 A2A agent 的静态入口。
|
# skill 里声明的 card 视为远端 A2A agent 的静态入口。
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@ -68,6 +69,9 @@ class DelegationManager:
|
|||||||
allowed_hosts: list[str] | None = None,
|
allowed_hosts: list[str] | None = None,
|
||||||
authz_config: Any | None = None,
|
authz_config: Any | None = None,
|
||||||
backend_identity: Any | None = None,
|
backend_identity: Any | None = None,
|
||||||
|
allow_local_delegation: bool = True,
|
||||||
|
allow_plugin_delegation: bool = True,
|
||||||
|
allow_local_fallback: bool = True,
|
||||||
):
|
):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@ -76,6 +80,9 @@ class DelegationManager:
|
|||||||
# local_executor 只负责“本地执行”,不再承担队列编排职责。
|
# local_executor 只负责“本地执行”,不再承担队列编排职责。
|
||||||
self.local_executor = local_executor
|
self.local_executor = local_executor
|
||||||
self.max_parallel_agents = max(1, max_parallel_agents)
|
self.max_parallel_agents = max(1, max_parallel_agents)
|
||||||
|
self.allow_local_delegation = allow_local_delegation
|
||||||
|
self.allow_plugin_delegation = allow_plugin_delegation
|
||||||
|
self.allow_local_fallback = allow_local_fallback
|
||||||
# A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。
|
# A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。
|
||||||
self.a2a_client = A2AClient(
|
self.a2a_client = A2AClient(
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
@ -88,6 +95,19 @@ class DelegationManager:
|
|||||||
self._running_tasks: dict[str, DelegationRun] = {}
|
self._running_tasks: dict[str, DelegationRun] = {}
|
||||||
self._direct_announcement_callback: DirectAnnouncementCallback | None = None
|
self._direct_announcement_callback: DirectAnnouncementCallback | None = None
|
||||||
|
|
||||||
|
_PERSISTENT_SUBAGENT_PATTERNS = (
|
||||||
|
re.compile(r"\bsub[\s-]?agent\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bpersistent\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bagents\.json\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bregistry\.json\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bsubagentctl\b", re.IGNORECASE),
|
||||||
|
re.compile(r"/api/subagents", re.IGNORECASE),
|
||||||
|
re.compile(r"workspace/agents", re.IGNORECASE),
|
||||||
|
re.compile(r"子智能体"),
|
||||||
|
re.compile(r"子 agent", re.IGNORECASE),
|
||||||
|
re.compile(r"持久化"),
|
||||||
|
)
|
||||||
|
|
||||||
def set_direct_announcement_callback(
|
def set_direct_announcement_callback(
|
||||||
self,
|
self,
|
||||||
callback: DirectAnnouncementCallback | None,
|
callback: DirectAnnouncementCallback | None,
|
||||||
@ -160,6 +180,7 @@ class DelegationManager:
|
|||||||
label: str,
|
label: str,
|
||||||
*,
|
*,
|
||||||
parent_run_id: str | None = None,
|
parent_run_id: str | None = None,
|
||||||
|
task: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# 单 agent 执行开始事件,供前端画执行树。
|
# 单 agent 执行开始事件,供前端画执行树。
|
||||||
await emit_process_event(
|
await emit_process_event(
|
||||||
@ -177,8 +198,21 @@ class DelegationManager:
|
|||||||
"protocol": descriptor.protocol,
|
"protocol": descriptor.protocol,
|
||||||
"support_group": descriptor.support_group,
|
"support_group": descriptor.support_group,
|
||||||
"support_streaming": descriptor.support_streaming,
|
"support_streaming": descriptor.support_streaming,
|
||||||
|
"delegated_task": task,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if task:
|
||||||
|
await emit_process_event(
|
||||||
|
"process_run_message",
|
||||||
|
run_id=run_id,
|
||||||
|
parent_run_id=parent_run_id,
|
||||||
|
actor_type="agent",
|
||||||
|
actor_id=descriptor.id,
|
||||||
|
actor_name=descriptor.name,
|
||||||
|
message_role="user",
|
||||||
|
text=task,
|
||||||
|
metadata={"source": "delegation_input"},
|
||||||
|
)
|
||||||
|
|
||||||
async def _emit_agent_finished(
|
async def _emit_agent_finished(
|
||||||
self,
|
self,
|
||||||
@ -386,7 +420,7 @@ class DelegationManager:
|
|||||||
|
|
||||||
# 单 agent 场景先解析目标,再执行。
|
# 单 agent 场景先解析目标,再执行。
|
||||||
descriptor = self._resolve_single(task, target, strategy)
|
descriptor = self._resolve_single(task, target, strategy)
|
||||||
await self._emit_agent_started(run_id, descriptor, label)
|
await self._emit_agent_started(run_id, descriptor, label, task=task)
|
||||||
progress_callback = self._build_progress_callback(
|
progress_callback = self._build_progress_callback(
|
||||||
origin,
|
origin,
|
||||||
descriptor,
|
descriptor,
|
||||||
@ -468,15 +502,20 @@ class DelegationManager:
|
|||||||
descriptor = self.registry.get_agent(target)
|
descriptor = self.registry.get_agent(target)
|
||||||
if descriptor is None:
|
if descriptor is None:
|
||||||
raise ValueError(f"Agent '{target}' not found")
|
raise ValueError(f"Agent '{target}' not found")
|
||||||
|
self._ensure_descriptor_allowed(descriptor)
|
||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
if strategy == "local":
|
if strategy == "local":
|
||||||
|
if not self.allow_local_fallback:
|
||||||
|
raise ValueError("Local fallback delegation is disabled")
|
||||||
descriptor = self.registry.get_agent("local-subagent")
|
descriptor = self.registry.get_agent("local-subagent")
|
||||||
if descriptor is None:
|
if descriptor is None:
|
||||||
raise ValueError("Local subagent is not available")
|
raise ValueError("Local subagent is not available")
|
||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
if strategy == "plugin":
|
if strategy == "plugin":
|
||||||
|
if not self.allow_plugin_delegation:
|
||||||
|
raise ValueError("Plugin delegation is disabled")
|
||||||
suggestions = [
|
suggestions = [
|
||||||
agent for agent in self.registry.suggest_agents(task)
|
agent for agent in self.registry.suggest_agents(task)
|
||||||
if agent.kind == "local_prompt" and agent.source == "plugin"
|
if agent.kind == "local_prompt" and agent.source == "plugin"
|
||||||
@ -494,15 +533,46 @@ class DelegationManager:
|
|||||||
return suggestions[0]
|
return suggestions[0]
|
||||||
raise ValueError("No matching A2A agent found")
|
raise ValueError("No matching A2A agent found")
|
||||||
|
|
||||||
suggestions = self.registry.suggest_agents(task, limit=1)
|
# Persistent sub-agent 管理是本地工作区变更任务,必须留在本地执行,
|
||||||
|
# 不能自动委派给远端 A2A agent,否则远端看不到本地规范和状态。
|
||||||
|
if self._is_persistent_subagent_task(task):
|
||||||
|
if not self.allow_local_fallback:
|
||||||
|
raise ValueError("Persistent sub-agent management requires local fallback delegation")
|
||||||
|
descriptor = self.registry.get_agent("local-subagent")
|
||||||
|
if descriptor is None:
|
||||||
|
raise ValueError("Local fallback agent is not available")
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
suggestions = [
|
||||||
|
agent for agent in self.registry.suggest_agents(task, limit=5)
|
||||||
|
if self._descriptor_allowed(agent)
|
||||||
|
]
|
||||||
if suggestions:
|
if suggestions:
|
||||||
return suggestions[0]
|
return suggestions[0]
|
||||||
# 自动路由一个都猜不到时,最后回到本地兜底 agent。
|
# 自动路由一个都猜不到时,最后回到本地兜底 agent。
|
||||||
|
if not self.allow_local_fallback:
|
||||||
|
raise ValueError("No allowed agent found for delegation")
|
||||||
descriptor = self.registry.get_agent("local-subagent")
|
descriptor = self.registry.get_agent("local-subagent")
|
||||||
if descriptor is None:
|
if descriptor is None:
|
||||||
raise ValueError("Local fallback agent is not available")
|
raise ValueError("Local fallback agent is not available")
|
||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_persistent_subagent_task(cls, task: str) -> bool:
|
||||||
|
text = (task or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
matched = sum(1 for pattern in cls._PERSISTENT_SUBAGENT_PATTERNS if pattern.search(text))
|
||||||
|
if matched >= 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
lowered = text.lower()
|
||||||
|
return (
|
||||||
|
("create" in lowered or "update" in lowered or "repair" in lowered or "fix" in lowered)
|
||||||
|
and ("subagent" in lowered or "sub-agent" in lowered)
|
||||||
|
)
|
||||||
|
|
||||||
async def _run_group(
|
async def _run_group(
|
||||||
self,
|
self,
|
||||||
task: str,
|
task: str,
|
||||||
@ -520,7 +590,10 @@ class DelegationManager:
|
|||||||
resolved_targets.append(target)
|
resolved_targets.append(target)
|
||||||
if not resolved_targets:
|
if not resolved_targets:
|
||||||
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
|
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
|
||||||
suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents)
|
suggestions = [
|
||||||
|
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2)
|
||||||
|
if self._descriptor_allowed(agent)
|
||||||
|
]
|
||||||
resolved_targets = [agent.id for agent in suggestions]
|
resolved_targets = [agent.id for agent in suggestions]
|
||||||
if not resolved_targets:
|
if not resolved_targets:
|
||||||
raise ValueError("No agents available for group delegation")
|
raise ValueError("No agents available for group delegation")
|
||||||
@ -533,6 +606,7 @@ class DelegationManager:
|
|||||||
if descriptor is None:
|
if descriptor is None:
|
||||||
missing.append(item)
|
missing.append(item)
|
||||||
else:
|
else:
|
||||||
|
self._ensure_descriptor_allowed(descriptor)
|
||||||
descriptors.append(descriptor)
|
descriptors.append(descriptor)
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
||||||
@ -544,7 +618,13 @@ class DelegationManager:
|
|||||||
child_run_id = new_run_id("agent")
|
child_run_id = new_run_id("agent")
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
try:
|
try:
|
||||||
await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id)
|
await self._emit_agent_started(
|
||||||
|
child_run_id,
|
||||||
|
descriptor,
|
||||||
|
label,
|
||||||
|
parent_run_id=run_id,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
result = await self._execute_descriptor(
|
result = await self._execute_descriptor(
|
||||||
descriptor,
|
descriptor,
|
||||||
task,
|
task,
|
||||||
@ -588,6 +668,12 @@ class DelegationManager:
|
|||||||
"""根据 descriptor 类型执行具体 agent。"""
|
"""根据 descriptor 类型执行具体 agent。"""
|
||||||
logger.info("Delegating '{}' to {}", label, descriptor.id)
|
logger.info("Delegating '{}' to {}", label, descriptor.id)
|
||||||
if descriptor.kind in {"local_fallback", "local_prompt"}:
|
if descriptor.kind in {"local_fallback", "local_prompt"}:
|
||||||
|
if not self.allow_local_delegation or (
|
||||||
|
descriptor.kind == "local_prompt" and not self.allow_plugin_delegation
|
||||||
|
) or (
|
||||||
|
descriptor.kind == "local_fallback" and not self.allow_local_fallback
|
||||||
|
):
|
||||||
|
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
|
||||||
# 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。
|
# 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。
|
||||||
with process_run_context(process_run_id):
|
with process_run_context(process_run_id):
|
||||||
return await self.local_executor.run_local_task(
|
return await self.local_executor.run_local_task(
|
||||||
@ -611,6 +697,19 @@ class DelegationManager:
|
|||||||
)
|
)
|
||||||
raise ValueError(f"Unsupported agent kind '{descriptor.kind}'")
|
raise ValueError(f"Unsupported agent kind '{descriptor.kind}'")
|
||||||
|
|
||||||
|
def _descriptor_allowed(self, descriptor: AgentDescriptor) -> bool:
|
||||||
|
if descriptor.kind == "local_fallback":
|
||||||
|
return self.allow_local_fallback and self.allow_local_delegation
|
||||||
|
if descriptor.kind == "local_prompt":
|
||||||
|
return self.allow_local_delegation and self.allow_plugin_delegation
|
||||||
|
if descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
|
||||||
|
if not self._descriptor_allowed(descriptor):
|
||||||
|
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
|
||||||
|
|
||||||
def _build_progress_callback(
|
def _build_progress_callback(
|
||||||
self,
|
self,
|
||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
|
|||||||
@ -76,6 +76,13 @@ class AgentLoop:
|
|||||||
channels_config: ChannelsConfig | None = None,
|
channels_config: ChannelsConfig | None = None,
|
||||||
authz_config: Any | None = None,
|
authz_config: Any | None = None,
|
||||||
backend_identity: Any | None = None,
|
backend_identity: Any | None = None,
|
||||||
|
allow_spawn: bool = True,
|
||||||
|
allow_message: bool = True,
|
||||||
|
allow_cron: bool = True,
|
||||||
|
include_local_fallback: bool = True,
|
||||||
|
allow_local_delegation: bool = True,
|
||||||
|
allow_plugin_delegation: bool = True,
|
||||||
|
include_plugin_agents: bool = True,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import A2AConfig, ExecToolConfig
|
from nanobot.config.schema import A2AConfig, ExecToolConfig
|
||||||
# 基础依赖与运行参数。
|
# 基础依赖与运行参数。
|
||||||
@ -95,6 +102,13 @@ class AgentLoop:
|
|||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.authz_config = authz_config
|
self.authz_config = authz_config
|
||||||
self.backend_identity = backend_identity
|
self.backend_identity = backend_identity
|
||||||
|
self.allow_spawn = allow_spawn
|
||||||
|
self.allow_message = allow_message
|
||||||
|
self.allow_cron = allow_cron
|
||||||
|
self.include_local_fallback = include_local_fallback
|
||||||
|
self.allow_local_delegation = allow_local_delegation
|
||||||
|
self.allow_plugin_delegation = allow_plugin_delegation
|
||||||
|
self.include_plugin_agents = include_plugin_agents
|
||||||
|
|
||||||
# 核心组件:上下文构建、会话管理、工具注册、子代理管理。
|
# 核心组件:上下文构建、会话管理、工具注册、子代理管理。
|
||||||
self.plugins = PluginLoader(workspace)
|
self.plugins = PluginLoader(workspace)
|
||||||
@ -106,6 +120,8 @@ class AgentLoop:
|
|||||||
skills=self.skills,
|
skills=self.skills,
|
||||||
allow_skill_cards=self.a2a_config.allow_skill_cards,
|
allow_skill_cards=self.a2a_config.allow_skill_cards,
|
||||||
allow_workspace_agents=self.a2a_config.allow_workspace_agents,
|
allow_workspace_agents=self.a2a_config.allow_workspace_agents,
|
||||||
|
include_local_fallback=self.include_local_fallback,
|
||||||
|
include_plugin_agents=self.include_plugin_agents,
|
||||||
)
|
)
|
||||||
self.context = ContextBuilder(
|
self.context = ContextBuilder(
|
||||||
workspace,
|
workspace,
|
||||||
@ -137,6 +153,9 @@ class AgentLoop:
|
|||||||
allowed_hosts=self.a2a_config.allowed_hosts,
|
allowed_hosts=self.a2a_config.allowed_hosts,
|
||||||
authz_config=self.authz_config,
|
authz_config=self.authz_config,
|
||||||
backend_identity=self.backend_identity,
|
backend_identity=self.backend_identity,
|
||||||
|
allow_local_delegation=self.allow_local_delegation,
|
||||||
|
allow_plugin_delegation=self.allow_plugin_delegation,
|
||||||
|
allow_local_fallback=self.include_local_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 运行时状态位。
|
# 运行时状态位。
|
||||||
@ -193,11 +212,13 @@ class AgentLoop:
|
|||||||
# 网络、消息、子代理工具按职责注册。
|
# 网络、消息、子代理工具按职责注册。
|
||||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||||
self.tools.register(WebFetchTool())
|
self.tools.register(WebFetchTool())
|
||||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
if self.allow_message:
|
||||||
self.tools.register(SpawnTool(manager=self.delegation))
|
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||||
|
if self.allow_spawn:
|
||||||
|
self.tools.register(SpawnTool(manager=self.delegation))
|
||||||
|
|
||||||
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
|
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
|
||||||
if self.cron_service:
|
if self.cron_service and self.allow_cron:
|
||||||
self.tools.register(CronTool(self.cron_service))
|
self.tools.register(CronTool(self.cron_service))
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
|
|||||||
@ -236,4 +236,11 @@ You are a delegated agent spawned by the main agent to complete a specific task.
|
|||||||
Your workspace is at: {self.workspace}
|
Your workspace is at: {self.workspace}
|
||||||
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
|
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
|
||||||
|
|
||||||
|
## Special Workflow
|
||||||
|
- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes.
|
||||||
|
- For persistent local sub-agents, follow only the canonical workflow from that skill.
|
||||||
|
- Do not manually create `workspace/agents/<id>/agent.json` as a substitute for a persistent sub-agent.
|
||||||
|
- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent.
|
||||||
|
- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/<id>_agent/AGENTS.json`.
|
||||||
|
|
||||||
When you have completed the task, provide a clear summary of your findings or actions.{extra}"""
|
When you have completed the task, provide a clear summary of your findings or actions.{extra}"""
|
||||||
|
|||||||
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 shlex
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@ -436,6 +437,21 @@ class MCPServerRequest(BaseModel):
|
|||||||
sensitive: bool = False
|
sensitive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SubagentRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
system_prompt: str = ""
|
||||||
|
model: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
delegation_mode: str = "remote_a2a_only"
|
||||||
|
allow_mcp: bool = True
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
aliases: list[str] = Field(default_factory=list)
|
||||||
|
mcp_servers: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class OutlookConnectionRequest(BaseModel):
|
class OutlookConnectionRequest(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
password: str
|
password: str
|
||||||
@ -733,6 +749,7 @@ def create_app(
|
|||||||
app.state.auth_tokens: dict[str, str] = {}
|
app.state.auth_tokens: dict[str, str] = {}
|
||||||
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
||||||
app.state.auth_file = _get_auth_file_path()
|
app.state.auth_file = _get_auth_file_path()
|
||||||
|
app.state.subagent_tasks: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
_register_routes(app)
|
_register_routes(app)
|
||||||
return app
|
return app
|
||||||
@ -1083,6 +1100,157 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
backend_identity=config.backend_identity,
|
backend_identity=config.backend_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload_id,
|
||||||
|
"error": {"code": code, "message": message},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_subagent_task(params: dict[str, Any]) -> str:
|
||||||
|
message = params.get("message")
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
raise ValueError("Missing 'message' object")
|
||||||
|
|
||||||
|
parts = message.get("parts")
|
||||||
|
if isinstance(parts, list):
|
||||||
|
for part in parts:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
text = str(part.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
raise ValueError("A2A message does not contain text content")
|
||||||
|
|
||||||
|
async def _run_subagent_task(agent_id: str, task: str) -> str:
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
spec = store.get_subagent(agent_id)
|
||||||
|
if spec is None or not spec.enabled:
|
||||||
|
raise HTTPException(status_code=404, detail="Sub-agent not found")
|
||||||
|
delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower()
|
||||||
|
allow_spawn = delegation_mode in {"remote_a2a_only", "full"}
|
||||||
|
allow_local = delegation_mode == "full"
|
||||||
|
|
||||||
|
provider = _make_provider(config)
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=app.state.bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=Path(spec.workspace),
|
||||||
|
model=spec.model or config.agents.defaults.model,
|
||||||
|
max_iterations=config.agents.defaults.max_tool_iterations,
|
||||||
|
temperature=config.agents.defaults.temperature,
|
||||||
|
max_tokens=config.agents.defaults.max_tokens,
|
||||||
|
memory_window=config.agents.defaults.memory_window,
|
||||||
|
brave_api_key=config.tools.web.search.api_key or None,
|
||||||
|
exec_config=config.tools.exec,
|
||||||
|
a2a_config=config.tools.a2a,
|
||||||
|
cron_service=None,
|
||||||
|
restrict_to_workspace=True,
|
||||||
|
session_manager=SessionManager(Path(spec.workspace)),
|
||||||
|
mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec),
|
||||||
|
authz_config=config.authz,
|
||||||
|
backend_identity=config.backend_identity,
|
||||||
|
allow_spawn=allow_spawn,
|
||||||
|
allow_message=False,
|
||||||
|
allow_cron=False,
|
||||||
|
include_local_fallback=allow_local,
|
||||||
|
allow_local_delegation=allow_local,
|
||||||
|
allow_plugin_delegation=allow_local,
|
||||||
|
include_plugin_agents=allow_local,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await loop.process_direct(
|
||||||
|
task,
|
||||||
|
session_key=f"a2a:{spec.id}",
|
||||||
|
channel="system",
|
||||||
|
chat_id=spec.id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
def _subagent_task_result(task_id: str) -> dict[str, Any] | None:
|
||||||
|
payload = app.state.subagent_tasks.get(task_id)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
"id": task_id,
|
||||||
|
"status": payload.get("status", "submitted"),
|
||||||
|
}
|
||||||
|
error = str(payload.get("error") or "").strip()
|
||||||
|
summary = str(payload.get("summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
result["summary"] = summary
|
||||||
|
if error:
|
||||||
|
result["summary"] = error
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if isinstance(metadata, dict) and metadata:
|
||||||
|
result["metadata"] = metadata
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None:
|
||||||
|
payload = app.state.subagent_tasks.get(task_id)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
task = payload.get("asyncio_task")
|
||||||
|
if isinstance(task, asyncio.Task) and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
payload["status"] = "cancelled"
|
||||||
|
payload["error"] = ""
|
||||||
|
payload.setdefault("summary", "Task cancelled")
|
||||||
|
return _subagent_task_result(task_id)
|
||||||
|
|
||||||
|
def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]:
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
app.state.subagent_tasks[task_id] = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"task": task,
|
||||||
|
"status": "submitted",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _runner() -> None:
|
||||||
|
app.state.subagent_tasks[task_id]["status"] = "working"
|
||||||
|
try:
|
||||||
|
summary = await _run_subagent_task(agent_id, task)
|
||||||
|
app.state.subagent_tasks[task_id]["status"] = "completed"
|
||||||
|
app.state.subagent_tasks[task_id]["summary"] = summary
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
app.state.subagent_tasks[task_id]["status"] = "cancelled"
|
||||||
|
app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled")
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
app.state.subagent_tasks[task_id]["status"] = "error"
|
||||||
|
app.state.subagent_tasks[task_id]["error"] = str(exc)
|
||||||
|
|
||||||
|
app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner())
|
||||||
|
return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"}
|
||||||
|
|
||||||
|
def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]:
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
payload = spec.to_dict()
|
||||||
|
base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id)
|
||||||
|
payload["base_url"] = base_url
|
||||||
|
payload["endpoint"] = f"{base_url}/rpc"
|
||||||
|
payload["card_url"] = f"{base_url}/.well-known/agent-card"
|
||||||
|
return payload
|
||||||
|
|
||||||
def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str:
|
def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str:
|
||||||
return _require_web_user(app, authorization)
|
return _require_web_user(app, authorization)
|
||||||
|
|
||||||
@ -1125,6 +1293,98 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host
|
frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host
|
||||||
return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/")
|
return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/")
|
||||||
|
|
||||||
|
@app.get("/subagents/{agent_id}/.well-known/agent-card")
|
||||||
|
@app.get("/subagents/{agent_id}/.well-known/agent-card.json")
|
||||||
|
@app.get("/subagents/{agent_id}/.well-known/agent.json")
|
||||||
|
async def get_subagent_card(agent_id: str):
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
spec = store.get_subagent(agent_id)
|
||||||
|
if spec is None or not spec.enabled:
|
||||||
|
raise HTTPException(status_code=404, detail="Sub-agent not found")
|
||||||
|
return LocalSubagentStore.build_agent_card(spec, config)
|
||||||
|
|
||||||
|
@app.post("/subagents/{agent_id}/rpc")
|
||||||
|
async def subagent_rpc(agent_id: str, payload: dict[str, Any]):
|
||||||
|
payload_id = payload.get("id")
|
||||||
|
method = str(payload.get("method") or "").strip()
|
||||||
|
params = payload.get("params")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
return _jsonrpc_error(payload_id, -32602, "Invalid params")
|
||||||
|
|
||||||
|
if method == "tasks/get":
|
||||||
|
task_id = str(params.get("id") or "").strip()
|
||||||
|
if not task_id:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, "Missing task id")
|
||||||
|
result = _subagent_task_result(task_id)
|
||||||
|
if result is None:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload_id,
|
||||||
|
"result": {"task": result},
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "tasks/cancel":
|
||||||
|
task_id = str(params.get("id") or "").strip()
|
||||||
|
if not task_id:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, "Missing task id")
|
||||||
|
result = _cancel_subagent_task(task_id)
|
||||||
|
if result is None:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload_id,
|
||||||
|
"result": {"task": result},
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "tasks/send":
|
||||||
|
try:
|
||||||
|
task = _extract_subagent_task(params)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, str(exc))
|
||||||
|
result = _start_subagent_task(agent_id, task)
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload_id,
|
||||||
|
"result": {"task": result},
|
||||||
|
}
|
||||||
|
|
||||||
|
if method != "message/send":
|
||||||
|
return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = _extract_subagent_task(params)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _jsonrpc_error(payload_id, -32602, str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await _run_subagent_task(agent_id, task)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Sub-agent RPC failed for {}", agent_id)
|
||||||
|
return _jsonrpc_error(payload_id, -32000, str(exc))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload_id,
|
||||||
|
"result": {
|
||||||
|
"message": {
|
||||||
|
"role": "agent",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"kind": "text",
|
||||||
|
"text": response,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def _local_backend_view(config: Config) -> dict[str, Any]:
|
def _local_backend_view(config: Config) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"backend_id": config.backend_identity.backend_id,
|
"backend_id": config.backend_identity.backend_id,
|
||||||
@ -2473,6 +2733,55 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@app.get("/api/subagents")
|
||||||
|
async def list_subagents():
|
||||||
|
"""List persistent local sub-agents."""
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
return [_serialize_subagent(spec, config) for spec in store.list_subagents()]
|
||||||
|
|
||||||
|
@app.get("/api/subagents/{agent_id}")
|
||||||
|
async def get_subagent(agent_id: str):
|
||||||
|
"""Get one persistent local sub-agent."""
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
spec = store.get_subagent(agent_id)
|
||||||
|
if spec is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Sub-agent not found")
|
||||||
|
return _serialize_subagent(spec, config)
|
||||||
|
|
||||||
|
@app.post("/api/subagents")
|
||||||
|
async def create_subagent(req: SubagentRequest):
|
||||||
|
"""Create or replace a persistent local sub-agent."""
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
spec = store.upsert_subagent(req.model_dump(), config)
|
||||||
|
return _serialize_subagent(spec, config)
|
||||||
|
|
||||||
|
@app.put("/api/subagents/{agent_id}")
|
||||||
|
async def update_subagent(agent_id: str, req: SubagentRequest):
|
||||||
|
"""Update a persistent local sub-agent."""
|
||||||
|
if agent_id != req.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Path id must match body id")
|
||||||
|
return await create_subagent(req)
|
||||||
|
|
||||||
|
@app.delete("/api/subagents/{agent_id}")
|
||||||
|
async def delete_subagent(agent_id: str):
|
||||||
|
"""Delete a persistent local sub-agent."""
|
||||||
|
from nanobot.agent.subagents import LocalSubagentStore
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
store = LocalSubagentStore(config.workspace_path)
|
||||||
|
if store.delete_subagent(agent_id):
|
||||||
|
return {"ok": True, "id": agent_id}
|
||||||
|
raise HTTPException(status_code=404, detail="Sub-agent not found")
|
||||||
|
|
||||||
@app.get("/api/agents")
|
@app.get("/api/agents")
|
||||||
async def list_agents():
|
async def list_agents():
|
||||||
"""List unified agents from workspace, plugins, skills, and local fallback."""
|
"""List unified agents from workspace, plugins, skills, and local fallback."""
|
||||||
|
|||||||
@ -1,11 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Tags,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
|
import {
|
||||||
|
addAgent,
|
||||||
|
createSubagent,
|
||||||
|
deleteAgent,
|
||||||
|
deleteSubagent,
|
||||||
|
listAgents,
|
||||||
|
listSubagents,
|
||||||
|
refreshAgents,
|
||||||
|
updateSubagent,
|
||||||
|
} from '@/lib/api';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { UiAgentDescriptor } from '@/types';
|
import type { UiAgentDescriptor, UiSubagentDescriptor } from '@/types';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -13,9 +32,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_AGENT_FORM = {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -30,17 +51,69 @@ const EMPTY_FORM = {
|
|||||||
aliases: '',
|
aliases: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_SUBAGENT_FORM = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
system_prompt: '',
|
||||||
|
model: '',
|
||||||
|
delegation_mode: 'remote_a2a_only',
|
||||||
|
enabled: true,
|
||||||
|
allow_mcp: true,
|
||||||
|
tags: '',
|
||||||
|
aliases: '',
|
||||||
|
metadata_json: '{}',
|
||||||
|
mcp_servers_json: '{}',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatJson(value: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string, label: string): Record<string, unknown> {
|
||||||
|
const probe = raw.trim();
|
||||||
|
if (!probe) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(probe);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 需要是合法 JSON`);
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${label} 需要是 JSON 对象`);
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> {
|
||||||
|
const parsed = parseJsonObject(raw, label);
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed as Record<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const cachedAgents = useChatStore((s) => s.agentRegistry);
|
const cachedAgents = useChatStore((s) => s.agentRegistry);
|
||||||
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
|
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
|
||||||
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
|
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
|
||||||
|
const [subagents, setSubagents] = useState<UiSubagentDescriptor[]>([]);
|
||||||
const [loading, setLoading] = useState(cachedAgents.length === 0);
|
const [loading, setLoading] = useState(cachedAgents.length === 0);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [subagentDialogOpen, setSubagentDialogOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [agentSubmitting, setAgentSubmitting] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [subagentSubmitting, setSubagentSubmitting] = useState(false);
|
||||||
|
const [agentAdvancedOpen, setAgentAdvancedOpen] = useState(false);
|
||||||
|
const [subagentAdvancedOpen, setSubagentAdvancedOpen] = useState(false);
|
||||||
|
const [agentForm, setAgentForm] = useState(EMPTY_AGENT_FORM);
|
||||||
|
const [subagentForm, setSubagentForm] = useState(EMPTY_SUBAGENT_FORM);
|
||||||
|
const [editingSubagentId, setEditingSubagentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async (background = false) => {
|
const load = useCallback(async (background = false) => {
|
||||||
if (background) {
|
if (background) {
|
||||||
@ -50,9 +123,14 @@ export default function AgentsPage() {
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await listAgents();
|
const [agentData, subagentData] = await Promise.all([
|
||||||
const nextAgents = Array.isArray(data) ? data : [];
|
listAgents(),
|
||||||
|
listSubagents(),
|
||||||
|
]);
|
||||||
|
const nextAgents = Array.isArray(agentData) ? agentData : [];
|
||||||
|
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
|
||||||
setAgents(nextAgents);
|
setAgents(nextAgents);
|
||||||
|
setSubagents(nextSubagents);
|
||||||
setCachedAgents(nextAgents);
|
setCachedAgents(nextAgents);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '加载智能体失败');
|
setError(err.message || '加载智能体失败');
|
||||||
@ -73,9 +151,14 @@ export default function AgentsPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const data = await refreshAgents();
|
const [agentData, subagentData] = await Promise.all([
|
||||||
const nextAgents = data.agents || [];
|
refreshAgents(),
|
||||||
|
listSubagents(),
|
||||||
|
]);
|
||||||
|
const nextAgents = agentData.agents || [];
|
||||||
|
const nextSubagents = Array.isArray(subagentData) ? subagentData : [];
|
||||||
setAgents(nextAgents);
|
setAgents(nextAgents);
|
||||||
|
setSubagents(nextSubagents);
|
||||||
setCachedAgents(nextAgents);
|
setCachedAgents(nextAgents);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '刷新智能体失败');
|
setError(err.message || '刷新智能体失败');
|
||||||
@ -84,59 +167,133 @@ export default function AgentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogOpenChange = (open: boolean) => {
|
const handleAgentDialogOpenChange = (open: boolean) => {
|
||||||
setDialogOpen(open);
|
setAgentDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setAdvancedOpen(false);
|
setAgentAdvancedOpen(false);
|
||||||
setForm(EMPTY_FORM);
|
setAgentForm(EMPTY_AGENT_FORM);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleSubagentDialogOpenChange = (open: boolean) => {
|
||||||
|
setSubagentDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSubagentAdvancedOpen(false);
|
||||||
|
setEditingSubagentId(null);
|
||||||
|
setSubagentForm(EMPTY_SUBAGENT_FORM);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAgent = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
|
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim());
|
||||||
if (!hasAddress) {
|
if (!hasAddress) {
|
||||||
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
|
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setAgentSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await addAgent({
|
await addAgent({
|
||||||
id: form.id || undefined,
|
id: agentForm.id || undefined,
|
||||||
name: form.name || undefined,
|
name: agentForm.name || undefined,
|
||||||
description: form.description || undefined,
|
description: agentForm.description || undefined,
|
||||||
protocol: 'a2a',
|
protocol: 'a2a',
|
||||||
base_url: form.base_url || undefined,
|
base_url: agentForm.base_url || undefined,
|
||||||
endpoint: form.endpoint || undefined,
|
endpoint: agentForm.endpoint || undefined,
|
||||||
card_url: form.card_url || undefined,
|
card_url: agentForm.card_url || undefined,
|
||||||
auth_env: form.auth_env || undefined,
|
auth_env: agentForm.auth_env || undefined,
|
||||||
auth_mode: form.auth_mode || 'none',
|
auth_mode: agentForm.auth_mode || 'none',
|
||||||
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
|
auth_audience: agentForm.auth_mode === 'none' ? undefined : agentForm.auth_audience || undefined,
|
||||||
auth_scopes: form.auth_mode === 'none'
|
auth_scopes: agentForm.auth_mode === 'none'
|
||||||
? []
|
? []
|
||||||
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
|
: agentForm.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
tags: agentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
aliases: agentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
});
|
});
|
||||||
handleDialogOpenChange(false);
|
handleAgentDialogOpenChange(false);
|
||||||
await load();
|
await load(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '新增智能体失败');
|
setError(err.message || '新增智能体失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setAgentSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (agentId: string) => {
|
const handleDeleteAgent = async (agentId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteAgent(agentId);
|
await deleteAgent(agentId);
|
||||||
await load();
|
await load(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '删除智能体失败');
|
setError(err.message || '删除智能体失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditSubagent = (subagent: UiSubagentDescriptor) => {
|
||||||
|
setEditingSubagentId(subagent.id);
|
||||||
|
setSubagentForm({
|
||||||
|
id: subagent.id,
|
||||||
|
name: subagent.name,
|
||||||
|
description: subagent.description,
|
||||||
|
system_prompt: subagent.system_prompt || '',
|
||||||
|
model: subagent.model || '',
|
||||||
|
delegation_mode: subagent.delegation_mode || 'remote_a2a_only',
|
||||||
|
enabled: subagent.enabled,
|
||||||
|
allow_mcp: subagent.allow_mcp,
|
||||||
|
tags: (subagent.tags || []).join(', '),
|
||||||
|
aliases: (subagent.aliases || []).join(', '),
|
||||||
|
metadata_json: formatJson(subagent.metadata || {}),
|
||||||
|
mcp_servers_json: formatJson(subagent.mcp_servers || {}),
|
||||||
|
});
|
||||||
|
setSubagentDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSubagent = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!subagentForm.id.trim()) {
|
||||||
|
setError('Sub-agent ID 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubagentSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: subagentForm.id.trim(),
|
||||||
|
name: subagentForm.name.trim() || subagentForm.id.trim(),
|
||||||
|
description: subagentForm.description.trim() || subagentForm.name.trim() || subagentForm.id.trim(),
|
||||||
|
system_prompt: subagentForm.system_prompt,
|
||||||
|
model: subagentForm.model.trim() || undefined,
|
||||||
|
enabled: subagentForm.enabled,
|
||||||
|
delegation_mode: subagentForm.delegation_mode,
|
||||||
|
allow_mcp: subagentForm.allow_mcp,
|
||||||
|
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
|
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||||
|
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'),
|
||||||
|
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'),
|
||||||
|
};
|
||||||
|
if (editingSubagentId) {
|
||||||
|
await updateSubagent(editingSubagentId, payload);
|
||||||
|
} else {
|
||||||
|
await createSubagent(payload);
|
||||||
|
}
|
||||||
|
handleSubagentDialogOpenChange(false);
|
||||||
|
await load(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '保存 Sub-Agent 失败');
|
||||||
|
} finally {
|
||||||
|
setSubagentSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteManagedSubagent = async (subagentId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSubagent(subagentId);
|
||||||
|
await load(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '删除 Sub-Agent 失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@ -154,17 +311,17 @@ export default function AgentsPage() {
|
|||||||
智能体
|
智能体
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
管理工作区智能体,并查看来自插件、技能和内置能力的可委派目标。
|
管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">
|
<Button size="sm" variant="outline">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
新增智能体
|
新增智能体
|
||||||
</Button>
|
</Button>
|
||||||
@ -173,53 +330,51 @@ export default function AgentsPage() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新增工作区智能体</DialogTitle>
|
<DialogTitle>新增工作区智能体</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="space-y-4" onSubmit={handleCreate}>
|
<form className="space-y-4" onSubmit={handleCreateAgent}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="base_url">A2A 部署地址</Label>
|
<Label htmlFor="base_url">A2A 部署地址</Label>
|
||||||
<Input
|
<Input
|
||||||
id="base_url"
|
id="base_url"
|
||||||
value={form.base_url}
|
value={agentForm.base_url}
|
||||||
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
|
onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))}
|
||||||
placeholder="https://agent.example.com 或 agent.example.com:19090"
|
placeholder="https://agent.example.com 或 agent.example.com:19090"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
默认只需要填写部署地址。保存时会自动读取
|
默认只需要填写部署地址。保存时会自动读取
|
||||||
<code className="mx-1">/.well-known/agent-card</code>
|
<code className="mx-1">/.well-known</code>
|
||||||
、<code className="mx-1">/.well-known/agent-card.json</code>
|
路径并补齐 card 信息。
|
||||||
和<code className="mx-1">/.well-known/agent.json</code>
|
|
||||||
,并补齐 ID、名称、描述、接口地址等信息。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button type="button" variant="outline" className="w-full justify-between">
|
<Button type="button" variant="outline" className="w-full justify-between">
|
||||||
高级设置(可选)
|
高级设置(可选)
|
||||||
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-4 pt-4">
|
<CollapsibleContent className="space-y-4 pt-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="id">ID</Label>
|
<Label htmlFor="id">ID</Label>
|
||||||
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" />
|
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">名称</Label>
|
<Label htmlFor="name">名称</Label>
|
||||||
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
|
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">描述</Label>
|
<Label htmlFor="description">描述</Label>
|
||||||
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
|
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="endpoint">接口地址</Label>
|
<Label htmlFor="endpoint">接口地址</Label>
|
||||||
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
|
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="card_url">卡片地址</Label>
|
<Label htmlFor="card_url">卡片地址</Label>
|
||||||
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
|
<Input id="card_url" value={agentForm.card_url} onChange={(e) => setAgentForm((s) => ({ ...s, card_url: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
@ -227,8 +382,8 @@ export default function AgentsPage() {
|
|||||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||||
<select
|
<select
|
||||||
id="auth_mode"
|
id="auth_mode"
|
||||||
value={form.auth_mode}
|
value={agentForm.auth_mode}
|
||||||
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
onChange={(e) => setAgentForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="none">none</option>
|
<option value="none">none</option>
|
||||||
@ -237,46 +392,211 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="auth_audience">Audience</Label>
|
<Label htmlFor="auth_audience">Audience</Label>
|
||||||
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
|
<Input id="auth_audience" value={agentForm.auth_audience} onChange={(e) => setAgentForm((s) => ({ ...s, auth_audience: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="auth_scopes">Scopes</Label>
|
<Label htmlFor="auth_scopes">Scopes</Label>
|
||||||
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
|
<Input id="auth_scopes" value={agentForm.auth_scopes} onChange={(e) => setAgentForm((s) => ({ ...s, auth_scopes: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="auth_env">认证环境变量</Label>
|
<Label htmlFor="auth_env">认证环境变量</Label>
|
||||||
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如:MY_AGENT_TOKEN" />
|
<Input id="auth_env" value={agentForm.auth_env} onChange={(e) => setAgentForm((s) => ({ ...s, auth_env: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tags">标签</Label>
|
<Label htmlFor="tags">标签</Label>
|
||||||
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
|
<Input id="tags" value={agentForm.tags} onChange={(e) => setAgentForm((s) => ({ ...s, tags: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="aliases">别名</Label>
|
<Label htmlFor="aliases">别名</Label>
|
||||||
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如:reviewer, audit-agent" />
|
<Input id="aliases" value={agentForm.aliases} onChange={(e) => setAgentForm((s) => ({ ...s, aliases: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
如果远端 card 需要额外鉴权信息,或者服务没有暴露标准
|
如果这是持久化本地 Sub-Agent,请改用下面的 Sub-Agent 面板,不要在这里单独删除 registry 记录。
|
||||||
<code className="mx-1">.well-known</code>
|
|
||||||
路径,再展开高级设置手动补充。
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={agentSubmitting}>
|
||||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
{agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog open={subagentDialogOpen} onOpenChange={handleSubagentDialogOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
新增 Sub-Agent
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingSubagentId ? '编辑 Sub-Agent' : '新增 Persistent Sub-Agent'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={handleSaveSubagent}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_id">ID</Label>
|
||||||
|
<Input
|
||||||
|
id="subagent_id"
|
||||||
|
value={subagentForm.id}
|
||||||
|
disabled={Boolean(editingSubagentId)}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, id: e.target.value }))}
|
||||||
|
placeholder="research-agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_name">名称</Label>
|
||||||
|
<Input
|
||||||
|
id="subagent_name"
|
||||||
|
value={subagentForm.name}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
placeholder="Research Agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_description">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
id="subagent_description"
|
||||||
|
rows={3}
|
||||||
|
value={subagentForm.description}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
|
||||||
|
placeholder="用于研究和资料整理的本地持久化子智能体"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_prompt">System Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
id="subagent_prompt"
|
||||||
|
rows={6}
|
||||||
|
value={subagentForm.system_prompt}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, system_prompt: e.target.value }))}
|
||||||
|
placeholder="Focus on research tasks, be concise, and cite concrete findings."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_model">模型</Label>
|
||||||
|
<Input
|
||||||
|
id="subagent_model"
|
||||||
|
value={subagentForm.model}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
|
||||||
|
placeholder="留空则继承主 Agent 默认模型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delegation_mode">委派模式</Label>
|
||||||
|
<select
|
||||||
|
id="delegation_mode"
|
||||||
|
value={subagentForm.delegation_mode}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, delegation_mode: e.target.value }))}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="remote_a2a_only">remote_a2a_only</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subagent_enabled">启用</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">关闭后仍保留 workspace 和配置</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="subagent_enabled"
|
||||||
|
checked={subagentForm.enabled}
|
||||||
|
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, enabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subagent_allow_mcp">允许 MCP</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">保留 MCP 配置并在运行时接入</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="subagent_allow_mcp"
|
||||||
|
checked={subagentForm.allow_mcp}
|
||||||
|
onCheckedChange={(checked) => setSubagentForm((s) => ({ ...s, allow_mcp: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_tags">标签</Label>
|
||||||
|
<Input
|
||||||
|
id="subagent_tags"
|
||||||
|
value={subagentForm.tags}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, tags: e.target.value }))}
|
||||||
|
placeholder="research, notes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_aliases">别名</Label>
|
||||||
|
<Input
|
||||||
|
id="subagent_aliases"
|
||||||
|
value={subagentForm.aliases}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, aliases: e.target.value }))}
|
||||||
|
placeholder="researcher, local-research"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" className="w-full justify-between">
|
||||||
|
原始 JSON 设置(Metadata / MCP)
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-4 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_metadata_json">Metadata JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id="subagent_metadata_json"
|
||||||
|
rows={6}
|
||||||
|
value={subagentForm.metadata_json}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, metadata_json: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subagent_mcp_json">MCP Servers JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id="subagent_mcp_json"
|
||||||
|
rows={8}
|
||||||
|
value={subagentForm.mcp_servers_json}
|
||||||
|
onChange={(e) => setSubagentForm((s) => ({ ...s, mcp_servers_json: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
创建后会自动生成独立 workspace、写入
|
||||||
|
<code className="mx-1">AGENTS.json</code>
|
||||||
|
和
|
||||||
|
<code className="mx-1">AGENTS.md</code>
|
||||||
|
,并注册到工作区智能体列表。
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={subagentSubmitting}>
|
||||||
|
{subagentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : editingSubagentId ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||||
|
{editingSubagentId ? '更新' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -291,73 +611,178 @@ export default function AgentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<Tabs defaultValue="agents" className="space-y-4">
|
||||||
{agents.map((agent) => {
|
<TabsList>
|
||||||
const isWorkspace = agent.source === 'workspace';
|
<TabsTrigger value="agents">委派目标</TabsTrigger>
|
||||||
return (
|
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
|
||||||
<Card key={agent.id}>
|
</TabsList>
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<TabsContent value="agents" className="space-y-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
{agents.map((agent) => {
|
||||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
const isWorkspace = agent.source === 'workspace';
|
||||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
const isManagedSubagent = Boolean(agent.metadata && agent.metadata.local_subagent);
|
||||||
{agent.description || '—'}
|
return (
|
||||||
</p>
|
<Card key={agent.id}>
|
||||||
</div>
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
<div className="min-w-0 flex-1">
|
||||||
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
||||||
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
||||||
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||||
</div>
|
{agent.description || '—'}
|
||||||
</div>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
||||||
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
||||||
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
{isManagedSubagent && <Badge className="bg-amber-600">受管 Sub-Agent</Badge>}
|
||||||
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
||||||
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
||||||
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
</div>
|
||||||
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience:</span> {agent.auth_audience}</div>}
|
</div>
|
||||||
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-3 pt-0">
|
||||||
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
|
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||||
<div className="space-y-2">
|
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
||||||
{agent.tags.length > 0 && (
|
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
||||||
<div className="flex items-start gap-2 flex-wrap">
|
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
||||||
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
||||||
{agent.tags.map((tag) => (
|
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
||||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience:</span> {agent.auth_audience}</div>}
|
||||||
))}
|
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
|
||||||
{agent.aliases.length > 0 && (
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
{agent.tags.length > 0 && (
|
||||||
<span className="font-medium text-foreground">别名:</span>
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
{agent.aliases.map((alias) => (
|
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||||
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
{agent.tags.map((tag) => (
|
||||||
))}
|
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agent.aliases.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">别名:</span>
|
||||||
|
{agent.aliases.map((alias) => (
|
||||||
|
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{isManagedSubagent ? (
|
||||||
|
<span className="text-xs text-muted-foreground">请在 Sub-Agent 面板管理</span>
|
||||||
|
) : isWorkspace ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">只读来源</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="subagents" className="space-y-4">
|
||||||
|
<Card className="border-border/70 bg-muted/20">
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
持久化 Sub-Agent 会在
|
||||||
|
<code className="mx-1">~/.nanobot/workspace/agents/<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>
|
||||||
)}
|
</CardHeader>
|
||||||
<div className="flex justify-end">
|
<CardContent className="space-y-3 pt-0">
|
||||||
{isWorkspace ? (
|
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
|
<div><span className="font-medium text-foreground">Workspace:</span> {subagent.workspace}</div>
|
||||||
|
<div><span className="font-medium text-foreground">Base URL:</span> {subagent.base_url}</div>
|
||||||
|
<div><span className="font-medium text-foreground">RPC:</span> {subagent.endpoint}</div>
|
||||||
|
<div><span className="font-medium text-foreground">Card:</span> {subagent.card_url}</div>
|
||||||
|
<div><span className="font-medium text-foreground">MCP Servers:</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
|
||||||
|
</div>
|
||||||
|
{subagent.system_prompt && (
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs font-medium text-foreground mb-1">System Prompt</div>
|
||||||
|
<p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-5">
|
||||||
|
{subagent.system_prompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(subagent.tags.length > 0 || subagent.aliases.length > 0) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{subagent.tags.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||||
|
{subagent.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subagent.aliases.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">别名:</span>
|
||||||
|
{subagent.aliases.map((alias) => (
|
||||||
|
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">只读来源</span>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
{subagents.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||||
|
还没有持久化 Sub-Agent。点击右上角“新增 Sub-Agent”开始创建。
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)}
|
||||||
})}
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -132,6 +133,8 @@ export default function CronPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
|
<TaskManagementTabs />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Clock className="w-6 h-6" />
|
<Clock className="w-6 h-6" />
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
|
||||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@ -19,6 +21,7 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||||||
|
|
||||||
@ -133,6 +136,19 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
}, [commands, input]);
|
}, [commands, input]);
|
||||||
|
|
||||||
|
const officeTasks = useMemo(
|
||||||
|
() => buildOfficeTaskList({
|
||||||
|
sessionId,
|
||||||
|
sessions,
|
||||||
|
processRuns,
|
||||||
|
processEvents,
|
||||||
|
processArtifacts,
|
||||||
|
}),
|
||||||
|
[processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await listSessions();
|
const list = await listSessions();
|
||||||
@ -544,6 +560,37 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{currentOfficeTask ? (
|
||||||
|
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
当前任务现场
|
||||||
|
</div>
|
||||||
|
<OfficeStatusBadge status={currentOfficeTask.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{currentOfficeTask.title}
|
||||||
|
<span className="ml-2">主 Agent: {currentOfficeTask.rootActorName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/office">查看全部 Office</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
|
||||||
|
查看任务现场
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<ChatWorkbench
|
<ChatWorkbench
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@ -554,7 +601,7 @@ export default function ChatPage() {
|
|||||||
processEvents={processEvents}
|
processEvents={processEvents}
|
||||||
processArtifacts={processArtifacts}
|
processArtifacts={processArtifacts}
|
||||||
selectedRunId={selectedRunId}
|
selectedRunId={selectedRunId}
|
||||||
onSelectRun={setSelectedRunId}
|
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
|
||||||
onCancelRun={handleCancelRun}
|
onCancelRun={handleCancelRun}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,10 +8,17 @@ import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen,
|
|||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
type NavItem = {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
matchPrefixes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ name: '对话', href: '/', icon: MessageSquare },
|
{ name: '对话', href: '/', icon: MessageSquare },
|
||||||
{ name: '状态', href: '/status', icon: Activity },
|
{ name: '状态', href: '/status', icon: Activity },
|
||||||
{ name: '定时任务', href: '/cron', icon: Clock },
|
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||||
{ name: '技能', href: '/skills', icon: Puzzle },
|
{ name: '技能', href: '/skills', icon: Puzzle },
|
||||||
{ name: '插件', href: '/plugins', icon: Blocks },
|
{ name: '插件', href: '/plugins', icon: Blocks },
|
||||||
{ name: '智能体', href: '/agents', icon: Bot },
|
{ name: '智能体', href: '/agents', icon: Bot },
|
||||||
@ -97,7 +104,7 @@ const Header = () => {
|
|||||||
const isActive =
|
const isActive =
|
||||||
item.href === '/'
|
item.href === '/'
|
||||||
? pathname === '/'
|
? pathname === '/'
|
||||||
: pathname.startsWith(item.href);
|
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -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 type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||||
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
|
|
||||||
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
||||||
|
|
||||||
export function ChatWorkbench({
|
export function ChatWorkbench({
|
||||||
@ -31,14 +30,15 @@ export function ChatWorkbench({
|
|||||||
onSelectRun: (runId: string) => void;
|
onSelectRun: (runId: string) => void;
|
||||||
onCancelRun: (runId: string) => void;
|
onCancelRun: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const selectedRun = processRuns.find((item) => item.run_id === selectedRunId) || processRuns[0] || null;
|
const selectedRun = selectedRunId
|
||||||
|
? processRuns.find((item) => item.run_id === selectedRunId) || null
|
||||||
|
: null;
|
||||||
const selectedRunEvents = selectedRun
|
const selectedRunEvents = selectedRun
|
||||||
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
|
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
|
||||||
: [];
|
: [];
|
||||||
const selectedRunArtifacts = selectedRun
|
const selectedRunArtifacts = selectedRun
|
||||||
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
|
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
|
||||||
: [];
|
: [];
|
||||||
const hasProcessLane = processRuns.length > 0;
|
|
||||||
const hasResultsPanel = Boolean(
|
const hasResultsPanel = Boolean(
|
||||||
selectedRun &&
|
selectedRun &&
|
||||||
(
|
(
|
||||||
@ -47,13 +47,9 @@ export function ChatWorkbench({
|
|||||||
selectedRunArtifacts.length > 0
|
selectedRunArtifacts.length > 0
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const desktopColumns = hasProcessLane && hasResultsPanel
|
const desktopColumns = hasResultsPanel
|
||||||
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]'
|
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||||
: hasProcessLane
|
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
|
||||||
: hasResultsPanel
|
|
||||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
|
||||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -64,19 +60,14 @@ export function ChatWorkbench({
|
|||||||
isThinking={isThinking}
|
isThinking={isThinking}
|
||||||
messagesEndRef={messagesEndRef}
|
messagesEndRef={messagesEndRef}
|
||||||
viewportRef={messageViewportRef}
|
viewportRef={messageViewportRef}
|
||||||
|
processRuns={processRuns}
|
||||||
|
processEvents={processEvents}
|
||||||
|
processArtifacts={processArtifacts}
|
||||||
|
selectedRunId={selectedRun?.run_id || null}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onCancelRun={onCancelRun}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{hasProcessLane && (
|
|
||||||
<div className="min-h-0">
|
|
||||||
<ProcessLane
|
|
||||||
runs={processRuns}
|
|
||||||
events={processEvents}
|
|
||||||
selectedRunId={selectedRun?.run_id || null}
|
|
||||||
onSelectRun={onSelectRun}
|
|
||||||
onCancelRun={onCancelRun}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasResultsPanel && (
|
{hasResultsPanel && (
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
<ArtifactSidebar
|
<ArtifactSidebar
|
||||||
@ -89,25 +80,24 @@ export function ChatWorkbench({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:hidden h-full">
|
<div className="lg:hidden h-full">
|
||||||
{!hasProcessLane && !hasResultsPanel ? (
|
{!hasResultsPanel ? (
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isThinking={isThinking}
|
isThinking={isThinking}
|
||||||
messagesEndRef={messagesEndRef}
|
messagesEndRef={messagesEndRef}
|
||||||
viewportRef={messageViewportRef}
|
viewportRef={messageViewportRef}
|
||||||
|
processRuns={processRuns}
|
||||||
|
processEvents={processEvents}
|
||||||
|
processArtifacts={processArtifacts}
|
||||||
|
selectedRunId={selectedRun?.run_id || null}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onCancelRun={onCancelRun}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||||
<div className="px-4 pt-3 border-b border-border">
|
<div className="px-4 pt-3 border-b border-border">
|
||||||
<TabsList
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
className={`grid w-full ${
|
|
||||||
hasProcessLane && hasResultsPanel
|
|
||||||
? 'grid-cols-3'
|
|
||||||
: 'grid-cols-2'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TabsTrigger value="chat">聊天</TabsTrigger>
|
<TabsTrigger value="chat">聊天</TabsTrigger>
|
||||||
{hasProcessLane && <TabsTrigger value="process">过程</TabsTrigger>}
|
|
||||||
{hasResultsPanel && <TabsTrigger value="results">结果</TabsTrigger>}
|
{hasResultsPanel && <TabsTrigger value="results">结果</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@ -117,19 +107,14 @@ export function ChatWorkbench({
|
|||||||
isThinking={isThinking}
|
isThinking={isThinking}
|
||||||
messagesEndRef={messagesEndRef}
|
messagesEndRef={messagesEndRef}
|
||||||
viewportRef={messageViewportRef}
|
viewportRef={messageViewportRef}
|
||||||
|
processRuns={processRuns}
|
||||||
|
processEvents={processEvents}
|
||||||
|
processArtifacts={processArtifacts}
|
||||||
|
selectedRunId={selectedRun?.run_id || null}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onCancelRun={onCancelRun}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{hasProcessLane && (
|
|
||||||
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
|
|
||||||
<ProcessLane
|
|
||||||
runs={processRuns}
|
|
||||||
events={processEvents}
|
|
||||||
selectedRunId={selectedRun?.run_id || null}
|
|
||||||
onSelectRun={onSelectRun}
|
|
||||||
onCancelRun={onCancelRun}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
{hasResultsPanel && (
|
{hasResultsPanel && (
|
||||||
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
||||||
<ArtifactSidebar
|
<ArtifactSidebar
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
|
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
|
||||||
|
|
||||||
import type { ChatMessage } from '@/types';
|
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||||
|
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
@ -108,21 +109,120 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentTeamGroup = {
|
||||||
|
rootRun: ProcessRun;
|
||||||
|
memberRuns: ProcessRun[];
|
||||||
|
startedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTimelineTime(value?: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
|
||||||
|
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
|
||||||
|
const groups = new Map<string, AgentTeamGroup>();
|
||||||
|
|
||||||
|
for (const run of processRuns) {
|
||||||
|
if (run.actor_type !== 'agent') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = run;
|
||||||
|
const seen = new Set<string>([run.run_id]);
|
||||||
|
let parentId = run.parent_run_id ?? null;
|
||||||
|
while (parentId) {
|
||||||
|
const parent = runMap.get(parentId);
|
||||||
|
if (!parent || seen.has(parent.run_id)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
root = parent;
|
||||||
|
seen.add(parent.run_id);
|
||||||
|
parentId = parent.parent_run_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = groups.get(root.run_id);
|
||||||
|
if (existing) {
|
||||||
|
existing.memberRuns.push(run);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
groups.set(root.run_id, {
|
||||||
|
rootRun: root,
|
||||||
|
memberRuns: [run],
|
||||||
|
startedAt: root.started_at || run.started_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.values())
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
|
||||||
|
const at = parseTimelineTime(a.started_at) ?? 0;
|
||||||
|
const bt = parseTimelineTime(b.started_at) ?? 0;
|
||||||
|
return at - bt;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const at = parseTimelineTime(a.startedAt) ?? 0;
|
||||||
|
const bt = parseTimelineTime(b.startedAt) ?? 0;
|
||||||
|
return at - bt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
isThinking,
|
isThinking,
|
||||||
messagesEndRef,
|
messagesEndRef,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
|
processRuns,
|
||||||
|
processEvents,
|
||||||
|
processArtifacts,
|
||||||
|
selectedRunId,
|
||||||
|
onSelectRun,
|
||||||
|
onCancelRun,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isThinking: boolean;
|
isThinking: boolean;
|
||||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||||
viewportRef: React.RefObject<HTMLDivElement>;
|
viewportRef: React.RefObject<HTMLDivElement>;
|
||||||
|
processRuns: ProcessRun[];
|
||||||
|
processEvents: ProcessEvent[];
|
||||||
|
processArtifacts: ProcessArtifact[];
|
||||||
|
selectedRunId: string | null;
|
||||||
|
onSelectRun: (runId: string) => void;
|
||||||
|
onCancelRun: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const teamGroups = React.useMemo(() => buildAgentTeamGroups(processRuns), [processRuns]);
|
||||||
|
const timelineItems = React.useMemo(() => {
|
||||||
|
const messageItems = messages.map((message, index) => ({
|
||||||
|
kind: 'message' as const,
|
||||||
|
key: `${message.role}:${message.timestamp || index}:${index}`,
|
||||||
|
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
||||||
|
order: index,
|
||||||
|
message,
|
||||||
|
}));
|
||||||
|
const teamItems = teamGroups.map((group, index) => ({
|
||||||
|
kind: 'team' as const,
|
||||||
|
key: `team:${group.rootRun.run_id}`,
|
||||||
|
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index,
|
||||||
|
order: messages.length + index,
|
||||||
|
group,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...messageItems, ...teamItems].sort((a, b) => {
|
||||||
|
if (a.sortTime !== b.sortTime) {
|
||||||
|
return a.sortTime - b.sortTime;
|
||||||
|
}
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
}, [messages, teamGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||||
<div className="max-w-4xl mx-auto py-4 space-y-4">
|
<div className="max-w-6xl mx-auto py-4 space-y-4">
|
||||||
{messages.length === 0 && !isThinking && (
|
{messages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||||
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
||||||
@ -130,9 +230,22 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, i) => (
|
{timelineItems.map((item) =>
|
||||||
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
|
item.kind === 'message' ? (
|
||||||
))}
|
<MessageBubble key={item.key} message={item.message} />
|
||||||
|
) : (
|
||||||
|
<AgentTeamBlock
|
||||||
|
key={item.key}
|
||||||
|
rootRun={item.group.rootRun}
|
||||||
|
memberRuns={item.group.memberRuns}
|
||||||
|
events={processEvents}
|
||||||
|
artifacts={processArtifacts}
|
||||||
|
selectedRunId={selectedRunId}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onCancelRun={onCancelRun}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{isThinking && (
|
{isThinking && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||||
|
|||||||
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,
|
OutlookOverview,
|
||||||
OutlookStatus,
|
OutlookStatus,
|
||||||
UiAgentDescriptor,
|
UiAgentDescriptor,
|
||||||
|
UiSubagentDescriptor,
|
||||||
UiMcpServerDescriptor,
|
UiMcpServerDescriptor,
|
||||||
WsEvent,
|
WsEvent,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
@ -631,6 +632,59 @@ export async function refreshAgents(): Promise<{ agents: UiAgentDescriptor[] }>
|
|||||||
return fetchJSON('/api/agents/refresh', { method: 'POST' });
|
return fetchJSON('/api/agents/refresh', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listSubagents(): Promise<UiSubagentDescriptor[]> {
|
||||||
|
return fetchJSON('/api/subagents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSubagent(payload: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
delegation_mode?: string;
|
||||||
|
allow_mcp?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
aliases?: string[];
|
||||||
|
mcp_servers?: Record<string, Record<string, unknown>>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<UiSubagentDescriptor> {
|
||||||
|
return fetchJSON('/api/subagents', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubagent(
|
||||||
|
subagentId: string,
|
||||||
|
payload: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
delegation_mode?: string;
|
||||||
|
allow_mcp?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
aliases?: string[];
|
||||||
|
mcp_servers?: Record<string, Record<string, unknown>>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Promise<UiSubagentDescriptor> {
|
||||||
|
return fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSubagent(subagentId: string): Promise<void> {
|
||||||
|
await fetchJSON(`/api/subagents/${encodeURIComponent(subagentId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
|
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
|
||||||
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
|
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
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'
|
? 'run_started'
|
||||||
: event.type === 'process_run_progress'
|
: event.type === 'process_run_progress'
|
||||||
? 'run_progress'
|
? 'run_progress'
|
||||||
|
: event.type === 'process_run_message'
|
||||||
|
? 'run_message'
|
||||||
: event.type === 'process_run_status'
|
: event.type === 'process_run_status'
|
||||||
? 'run_status'
|
? 'run_status'
|
||||||
: event.type === 'process_run_artifact'
|
: event.type === 'process_run_artifact'
|
||||||
@ -207,6 +209,7 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||||||
? event.summary
|
? event.summary
|
||||||
: undefined,
|
: undefined,
|
||||||
status: 'status' in event ? event.status : undefined,
|
status: 'status' in event ? event.status : undefined,
|
||||||
|
message_role: 'message_role' in event ? event.message_role : undefined,
|
||||||
metadata: 'metadata' in event ? event.metadata : undefined,
|
metadata: 'metadata' in event ? event.metadata : undefined,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
});
|
});
|
||||||
@ -225,7 +228,6 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||||||
started_at: event.created_at,
|
started_at: event.created_at,
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
});
|
});
|
||||||
nextSelectedRunId = event.run_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'process_run_status') {
|
if (event.type === 'process_run_status') {
|
||||||
@ -257,6 +259,20 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'process_run_message') {
|
||||||
|
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||||
|
nextRuns = upsertRun(nextRuns, {
|
||||||
|
run_id: event.run_id,
|
||||||
|
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||||
|
actor_type: event.actor_type,
|
||||||
|
actor_id: event.actor_id,
|
||||||
|
actor_name: event.actor_name,
|
||||||
|
title: current?.title || event.actor_name,
|
||||||
|
status: current?.status || 'running',
|
||||||
|
started_at: current?.started_at || event.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === 'process_run_artifact') {
|
if (event.type === 'process_run_artifact') {
|
||||||
nextArtifacts = upsertArtifact(nextArtifacts, {
|
nextArtifacts = upsertArtifact(nextArtifacts, {
|
||||||
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
|
artifact_id: `${event.run_id}:${event.created_at}:${event.title}`,
|
||||||
@ -273,7 +289,6 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
});
|
});
|
||||||
nextSelectedRunId = event.run_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'process_run_finished') {
|
if (event.type === 'process_run_finished') {
|
||||||
|
|||||||
@ -6,6 +6,14 @@ const nextConfig = {
|
|||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
|
webpack: (config) => {
|
||||||
|
config.resolve = config.resolve || {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...(config.resolve.alias || {}),
|
||||||
|
phaser3spectorjs: false,
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
1818
app-instance/frontend/office-ui.md
Normal file
1705
app-instance/frontend/package-lock.json
generated
@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit --incremental false",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
@ -66,6 +67,7 @@
|
|||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"pdfmake": "^0.2.20",
|
"pdfmake": "^0.2.20",
|
||||||
"pdfmake-with-chinese-fonts": "^1.0.16",
|
"pdfmake-with-chinese-fonts": "^1.0.16",
|
||||||
|
"phaser": "^3.90.0",
|
||||||
"postcss": "8.4.30",
|
"postcss": "8.4.30",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@ -88,6 +90,8 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pdfmake": "^0.2.12"
|
"@types/pdfmake": "^0.2.12",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 |