feat: 添加swarms团队编排功能并优化agent委派系统

- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

0
.codex Normal file
View File

View File

@ -1,3 +1,4 @@
https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65
# nano_project # nano_project
单机部署版运行结构: 单机部署版运行结构:

View File

@ -64,11 +64,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
COPY backend/nanobot/ ./nanobot/ COPY backend/nanobot/ ./nanobot/
COPY backend/bridge/ ./bridge/ COPY backend/bridge/ ./bridge/
COPY backend/third_party/swarms/ ./third_party/swarms/
RUN uv pip install --system --no-cache . RUN uv pip install --system --no-cache .
WORKDIR /opt/app/backend/bridge WORKDIR /opt/app/backend/bridge
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \ git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \
git config --global url."https://github.com/".insteadOf "git@github.com:" && \
npm config set registry "https://registry.npmjs.org" && \
npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \ npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \
npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \ npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \
npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \ npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \

View File

@ -23,11 +23,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
# Copy the full source and install # Copy the full source and install
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY bridge/ bridge/ COPY bridge/ bridge/
COPY third_party/swarms/ third_party/swarms/
RUN uv pip install --system --no-cache . RUN uv pip install --system --no-cache .
# Build the WhatsApp bridge # Build the WhatsApp bridge
WORKDIR /app/bridge WORKDIR /app/bridge
RUN npm install && npm run build RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \
git config --global url."https://github.com/".insteadOf "git@github.com:" && \
npm install && npm run build
WORKDIR /app WORKDIR /app
# Create config directory # Create config directory

View File

@ -22,7 +22,7 @@ from urllib.parse import urlparse, urlunparse
import httpx import httpx
from nanobot.agent.agent_registry import AgentDescriptor from nanobot.agent.agent_registry import AgentDescriptor
from nanobot.agent.run_result import AgentRunResult from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
class A2AError(RuntimeError): class A2AError(RuntimeError):
@ -204,7 +204,7 @@ class A2AClient:
def __init__( def __init__(
self, self,
timeout_seconds: int = 30, timeout_seconds: int = 600,
poll_interval_seconds: int = 2, poll_interval_seconds: int = 2,
card_cache_ttl_seconds: int = 300, card_cache_ttl_seconds: int = 300,
allowed_hosts: list[str] | None = None, allowed_hosts: list[str] | None = None,
@ -1123,10 +1123,13 @@ class A2AClient:
summary = state.build_summary(self) summary = state.build_summary(self)
if not summary: if not summary:
summary = self._extract_text(result) or json.dumps(result, ensure_ascii=False) summary = self._extract_text(result) or json.dumps(result, ensure_ascii=False)
status = self._normalize_status(result.get("status"))
if not has_meaningful_summary(summary):
status = "error"
return AgentRunResult( return AgentRunResult(
agent_id=agent.id, agent_id=agent.id,
agent_name=agent.name, agent_name=agent.name,
status=self._normalize_status(result.get("status")), status=status,
summary=summary, summary=summary,
raw=result, raw=result,
) )

View File

@ -21,6 +21,7 @@ from nanobot.agent.plugins import PluginLoader
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
_TOKEN_RE = re.compile(r"[a-z0-9_-]+") _TOKEN_RE = re.compile(r"[a-z0-9_-]+")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
@dataclass @dataclass
@ -55,7 +56,6 @@ class AgentDescriptor:
aliases: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list)
capabilities: dict[str, Any] = field(default_factory=dict) capabilities: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
support_group: bool = True
support_streaming: bool = False support_streaming: bool = False
def matches(self, target: str) -> bool: def matches(self, target: str) -> bool:
@ -236,7 +236,6 @@ class AgentRegistry:
kind="local_fallback", kind="local_fallback",
protocol=None, protocol=None,
aliases=["subagent", "local"], aliases=["subagent", "local"],
support_group=True,
) )
) )
@ -263,27 +262,51 @@ class AgentRegistry:
def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]: def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]:
"""基于简单词项打分为一段任务文本推荐 agent。""" """基于简单词项打分为一段任务文本推荐 agent。"""
tokens = {token for token in _TOKEN_RE.findall((query or "").lower()) if len(token) > 2} query_text = query or ""
if not tokens: query_lower = query_text.lower()
return [] tokens = {token for token in _TOKEN_RE.findall(query_lower) if len(token) > 2}
query_cjk_bigrams = self._cjk_bigrams(query_text)
scored: list[tuple[int, AgentDescriptor]] = [] scored: list[tuple[int, AgentDescriptor]] = []
for agent in self.list_agents(include_local_fallback=False): for agent in self.list_agents(include_local_fallback=False):
haystack = agent.searchable_text() haystack = agent.searchable_text()
haystack_cjk_bigrams = self._cjk_bigrams(haystack)
score = 0 score = 0
for token in tokens: for token in tokens:
# token 命中一次给基础分。 # token 命中一次给基础分。
if token in haystack: if token in haystack:
score += 2 score += 2
# 如果查询里直接出现了 agent 名或 id再给更高权重。 # 如果查询里直接出现了 agent 名或 id再给更高权重。
if agent.name.lower() in query.lower() or agent.id.lower() in query.lower(): if agent.name.lower() in query_lower or agent.id.lower() in query_lower:
score += 5 score += 5
for phrase in [agent.name, agent.id, *agent.tags, *agent.aliases]:
phrase_text = str(phrase or "").strip()
if not phrase_text:
continue
if phrase_text.lower() in query_lower or phrase_text in query_text:
score += 3
if query_cjk_bigrams and haystack_cjk_bigrams:
# 中文任务没有空格分词,先用 bigram overlap 做粗粒度召回。
score += min(6, len(query_cjk_bigrams & haystack_cjk_bigrams))
if score > 0: if score > 0:
scored.append((score, agent)) scored.append((score, agent))
scored.sort(key=lambda item: (-item[0], item[1].name.lower())) scored.sort(key=lambda item: (-item[0], item[1].name.lower()))
return [agent for _, agent in scored[:limit]] return [agent for _, agent in scored[:limit]]
@staticmethod
def _cjk_bigrams(text: str) -> set[str]:
"""提取中文 bigram用于中文任务的轻量召回。"""
chunks = _CJK_RE.findall(str(text or ""))
result: set[str] = set()
for chunk in chunks:
if len(chunk) == 1:
result.add(chunk)
continue
for index in range(len(chunk) - 1):
result.add(chunk[index:index + 2])
return result
def build_agents_summary(self) -> str: def build_agents_summary(self) -> str:
"""把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。""" """把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。"""
agents = self.list_agents() agents = self.list_agents()
@ -310,9 +333,6 @@ class AgentRegistry:
lines.append(f" <protocol>{esc(agent.protocol)}</protocol>") lines.append(f" <protocol>{esc(agent.protocol)}</protocol>")
if agent.tags: if agent.tags:
lines.append(f" <tags>{esc(', '.join(agent.tags))}</tags>") lines.append(f" <tags>{esc(', '.join(agent.tags))}</tags>")
lines.append(
f" <supports-group>{str(agent.support_group).lower()}</supports-group>"
)
lines.append(" </agent>") lines.append(" </agent>")
lines.append("</agents>") lines.append("</agents>")
return "\n".join(lines) return "\n".join(lines)
@ -358,7 +378,6 @@ class AgentRegistry:
], ],
capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {}, capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {},
metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {}, metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {},
support_group=bool(record.get("support_group", True)),
support_streaming=bool(record.get("support_streaming", False)), support_streaming=bool(record.get("support_streaming", False)),
) )
@ -396,6 +415,5 @@ class AgentRegistry:
], ],
capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {}, capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {},
metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {}, metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {},
support_group=bool(card.get("support_group", True)),
support_streaming=bool(card.get("support_streaming", False)), support_streaming=bool(card.get("support_streaming", False)),
) )

View File

@ -28,6 +28,8 @@ from nanobot.agent.process_events import (
process_run_context, process_run_context,
) )
from nanobot.agent.run_result import AgentRunResult from nanobot.agent.run_result import AgentRunResult
from nanobot.agent_team.orchestrator import AgentTeamOrchestrator
from nanobot.agent_team.types import BridgeResult
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
@ -61,12 +63,13 @@ class DelegationManager:
def __init__( def __init__(
self, self,
provider: LLMProvider, provider: LLMProvider,
model: str | None,
workspace: Path, workspace: Path,
bus: MessageBus, bus: MessageBus,
registry: AgentRegistry, registry: AgentRegistry,
skills_loader: "SkillsLoader | None", skills_loader: "SkillsLoader | None",
local_executor: Any, local_executor: Any,
timeout_seconds: int = 30, timeout_seconds: int = 600,
poll_interval_seconds: int = 2, poll_interval_seconds: int = 2,
card_cache_ttl_seconds: int = 300, card_cache_ttl_seconds: int = 300,
max_parallel_agents: int = 4, max_parallel_agents: int = 4,
@ -76,6 +79,7 @@ class DelegationManager:
allow_local_delegation: bool = True, allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True, allow_plugin_delegation: bool = True,
allow_local_fallback: bool = True, allow_local_fallback: bool = True,
gateway_port: int = 18790,
): ):
self.provider = provider self.provider = provider
self.workspace = workspace self.workspace = workspace
@ -97,6 +101,18 @@ class DelegationManager:
authz_config=authz_config, authz_config=authz_config,
backend_identity=backend_identity, backend_identity=backend_identity,
) )
# 新 orchestrator 只负责 agent team 路径;单 agent 委派仍走原有逻辑。
self.agent_team_orchestrator = AgentTeamOrchestrator(
workspace=workspace,
provider=provider,
model=model,
registry=registry,
bus=bus,
local_executor=local_executor,
member_runner=self._run_team_member_for_swarms,
max_parallel_agents=self.max_parallel_agents,
gateway_port=gateway_port,
)
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
@ -273,6 +289,20 @@ class DelegationManager:
"""返回当前正在执行的委派数量。""" """返回当前正在执行的委派数量。"""
return len(self._running_tasks) return len(self._running_tasks)
@staticmethod
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""删除空值,避免过程事件 metadata 出现大量噪声字段。"""
cleaned: dict[str, Any] = {}
for key, value in metadata.items():
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
if isinstance(value, (list, tuple, set, dict)) and not value:
continue
cleaned[key] = value
return cleaned
@staticmethod @staticmethod
def _ui_status(status: str | None) -> str: def _ui_status(status: str | None) -> str:
"""把底层状态归一化成前端更稳定的显示状态。""" """把底层状态归一化成前端更稳定的显示状态。"""
@ -287,6 +317,29 @@ class DelegationManager:
return "error" return "error"
return probe or "running" return probe or "running"
async def _emit_team_progress(
self,
run_id: str,
text: str,
*,
stage_label: str,
metadata: dict[str, Any] | None = None,
) -> None:
"""为 agent team 根 run 发一条过程可观察事件。"""
await emit_process_event(
"process_run_progress",
run_id=run_id,
actor_type="system",
actor_id="agent-group",
actor_name="Agent Team",
text=text,
metadata=self._clean_metadata({
"source": "agent_team_dispatch",
"stage_label": stage_label,
**(metadata or {}),
}),
)
async def _emit_agent_started( async def _emit_agent_started(
self, self,
run_id: str, run_id: str,
@ -310,7 +363,6 @@ class DelegationManager:
metadata={ metadata={
"kind": descriptor.kind, "kind": descriptor.kind,
"protocol": descriptor.protocol, "protocol": descriptor.protocol,
"support_group": descriptor.support_group,
"support_streaming": descriptor.support_streaming, "support_streaming": descriptor.support_streaming,
"delegated_task": task, "delegated_task": task,
}, },
@ -371,31 +423,56 @@ class DelegationManager:
actor_type="system", actor_type="system",
actor_id="agent-group", actor_id="agent-group",
actor_name="Agent Team", actor_name="Agent Team",
source="agent_team",
title=label, title=label,
status="running", status="running",
metadata={"targets": targets}, metadata=self._clean_metadata({
"source": "agent_team_dispatch",
"phase": "dispatch",
"stage_label": "团队任务已创建",
"planned_targets": targets,
"selected_targets": targets,
"selected_count": len(targets),
}),
) )
async def _emit_group_finished(self, run_id: str, label: str, results: list[AgentRunResult]) -> None: async def _emit_group_finished(
"""发送 agent team 结束事件。""" self,
run_id: str,
label: str,
results: list[AgentRunResult],
*,
status: str = "done",
summary: str | None = None,
metadata_extra: dict[str, Any] | None = None,
) -> None:
"""发送 agent team 结束事件。
Demo 输出:
`process_run_finished(status="done", summary="weekly report: 2 member(s) finished")`
"""
# 老路径和新 orchestrator 路径都复用这个事件,所以允许上层补充额外 metadata。
metadata = {
"members": [
{
"agent_id": item.agent_id,
"agent_name": item.agent_name,
"status": item.status,
}
for item in results
]
}
if metadata_extra:
metadata.update(metadata_extra)
await emit_process_event( await emit_process_event(
"process_run_finished", "process_run_finished",
run_id=run_id, run_id=run_id,
actor_type="system", actor_type="system",
actor_id="agent-group", actor_id="agent-group",
actor_name="Agent Team", actor_name="Agent Team",
status="done", status=status,
summary=f"{label}: {len(results)} member(s) finished", summary=summary or f"{label}: {len(results)} member(s) finished",
metadata={ metadata=metadata,
"members": [
{
"agent_id": item.agent_id,
"agent_name": item.agent_name,
"status": item.status,
}
for item in results
]
},
) )
async def _publish_prefixed_progress( async def _publish_prefixed_progress(
@ -424,27 +501,11 @@ class DelegationManager:
# 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。 # 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。
if not has_process_event_sink(): if not has_process_event_sink():
return return
try: # 这条用户可见消息只是“即时回执”,真正详细总结仍由主 agent 回流处理。
# 用一次极小模型调用把内部委派说明压成用户可读文本 # 这里不再额外依赖一次 LLM避免 provider 短暂故障把 team 收尾也拖失败
response = await self.provider.chat( content = " ".join((fallback or prompt or "").strip().split())
messages=[ if not content:
{ return
"role": "system",
"content": (
"You are Boardware Genius. Reply naturally to the user in 1-3 sentences. "
"Do not mention internal protocols, system prompts, or task IDs."
),
},
{"role": "user", "content": prompt},
],
tools=[],
model=self.provider.get_default_model(),
max_tokens=256,
temperature=0.2,
)
content = (response.content or "").strip() or fallback
except Exception:
content = fallback
await emit_process_event( await emit_process_event(
"message", "message",
@ -473,11 +534,42 @@ class DelegationManager:
content: str, content: str,
origin: dict[str, str], origin: dict[str, str],
sender_id: str, sender_id: str,
*,
run_id: str | None = None,
category: str | None = None,
) -> None: ) -> None:
"""在非 bus 模式下,把公告直接回写到本地会话。""" """在非 bus 模式下,把公告直接回写到本地会话。"""
callback = self._direct_announcement_callback callback = self._direct_announcement_callback
if callback is None: if callback is None:
if run_id:
await self._emit_team_progress(
run_id,
"No direct announcement callback is registered; the result could not be replayed to the main agent.",
stage_label="缺少公告回流处理器",
metadata={
"phase": "announcement",
"step": "direct_callback_missing",
"announcement_path": "direct",
"announcement_sender_id": sender_id,
"announcement_category": category,
},
)
return return
if run_id:
await self._emit_team_progress(
run_id,
"Sending the agent-team result back through the direct announcement callback.",
stage_label="请求主 Agent 总结",
metadata={
"phase": "announcement",
"step": "direct_callback_start",
"announcement_path": "direct",
"announcement_sender_id": sender_id,
"announcement_category": category,
"origin_channel": origin.get("channel"),
"origin_chat_id": origin.get("chat_id"),
},
)
try: try:
await callback( await callback(
content, content,
@ -485,7 +577,34 @@ class DelegationManager:
sender_id, sender_id,
not has_process_event_sink(), not has_process_event_sink(),
) )
if run_id:
await self._emit_team_progress(
run_id,
"The direct announcement callback completed successfully.",
stage_label="主 Agent 总结完成",
metadata={
"phase": "announcement",
"step": "direct_callback_complete",
"announcement_path": "direct",
"announcement_sender_id": sender_id,
"announcement_category": category,
},
)
except Exception as exc: except Exception as exc:
if run_id:
await self._emit_team_progress(
run_id,
f"Direct announcement callback failed: {exc}",
stage_label="主 Agent 总结失败",
metadata={
"phase": "announcement",
"step": "direct_callback_failed",
"announcement_path": "direct",
"announcement_sender_id": sender_id,
"announcement_category": category,
"error": str(exc),
},
)
logger.warning("Failed to handle direct delegation announcement: {}", exc) logger.warning("Failed to handle direct delegation announcement: {}", exc)
async def _run_dispatch( async def _run_dispatch(
@ -510,23 +629,80 @@ class DelegationManager:
if is_group: if is_group:
planned_targets = list(targets) planned_targets = list(targets)
await self._emit_group_started(run_id, label, planned_targets) await self._emit_group_started(run_id, label, planned_targets)
results = await self._run_group( await self._emit_team_progress(
task, run_id,
label, "Agent team dispatch accepted and moved into swarms orchestration.",
None, stage_label="开始团队编排",
targets, metadata={
strategy, "phase": "dispatch",
skills, "strategy": strategy,
origin=origin, "execution_path": "swarms",
run_id=run_id, "announce_via_bus": announce_via_bus,
announce_via_bus=announce_via_bus, "requested_targets": planned_targets,
},
) )
await self._emit_group_finished(run_id, label, results) logger.info(
await self._announce_group_result( "Agent team [{}] dispatch started: mode=swarms announce_via_bus={} requested_targets={}",
run_id,
announce_via_bus,
planned_targets,
)
await self._emit_team_progress(
run_id,
"DelegationManager handed the task to AgentTeamOrchestrator.",
stage_label="编排器接管任务",
metadata={
"phase": "orchestrator",
"step": "handoff_to_orchestrator",
"requested_targets": planned_targets,
},
)
orchestrated = await self.agent_team_orchestrator.run_task(
task=task,
label=label,
skills=skills,
origin=origin,
announce_via_bus=announce_via_bus,
run_id=run_id,
)
await self._emit_team_progress(
run_id,
"AgentTeamOrchestrator returned a final bridge result.",
stage_label="编排器已返回结果",
metadata={
"phase": "orchestrator",
"step": "orchestrator_result_ready",
"execution_mode": orchestrated.mode.value,
"candidate_procedure_id": (
orchestrated.candidate_procedure.id
if orchestrated.candidate_procedure is not None
else None
),
"attempt_count": len(orchestrated.attempts),
"success": orchestrated.success,
},
)
await self._emit_group_finished(
run_id,
label,
orchestrated.last_member_results(),
status="done" if orchestrated.success else "error",
summary=orchestrated.summary,
metadata_extra={
"execution_mode": orchestrated.mode.value,
"candidate_procedure_id": (
orchestrated.candidate_procedure.id
if orchestrated.candidate_procedure is not None
else None
),
"attempts": [attempt.to_dict() for attempt in orchestrated.attempts],
},
)
await self._announce_orchestrator_result(
run_id, run_id,
label, label,
task, task,
results, orchestrated,
origin, origin,
announce_via_bus=announce_via_bus, announce_via_bus=announce_via_bus,
) )
@ -591,6 +767,16 @@ class DelegationManager:
summary=f"Error: {exc}", summary=f"Error: {exc}",
) )
if is_group: if is_group:
await self._emit_team_progress(
run_id,
f"Agent team execution failed before announcement: {exc}",
stage_label="团队执行失败",
metadata={
"phase": "error",
"step": "dispatch_failed",
"error": str(exc),
},
)
await emit_process_event( await emit_process_event(
"process_run_finished", "process_run_finished",
run_id=run_id, run_id=run_id,
@ -777,94 +963,57 @@ class DelegationManager:
and ("subagent" in lowered or "sub-agent" in lowered) and ("subagent" in lowered or "sub-agent" in lowered)
) )
async def _run_group( async def _run_team_member_for_swarms(
self, self,
descriptor: AgentDescriptor,
task: str, task: str,
label: str, parent_run_id: str,
target: str | None,
targets: list[str],
strategy: str,
skills: list[str], skills: list[str],
origin: dict[str, str], ) -> AgentRunResult:
run_id: str, """Execute one swarms-selected nanobot agent as a process child run."""
announce_via_bus: bool, state = self._running_tasks.get(parent_run_id)
) -> list[AgentRunResult]: label = "Agent Team" if state is None else state.label
"""并行执行一组 agent并汇总结果。""" origin = {"channel": "system", "chat_id": "direct"} if state is None else state.origin
resolved_targets = list(targets) announce_via_bus = True if state is None else state.announce_via_bus
if target: child_run_id = new_run_id("agent")
resolved_targets.append(target) try:
if not resolved_targets: self._ensure_descriptor_allowed(descriptor)
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。 await self._emit_agent_started(
suggestions = [ child_run_id,
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2) descriptor,
if self._descriptor_allowed(agent) label,
] parent_run_id=parent_run_id,
resolved_targets = [agent.id for agent in suggestions] task=task,
if not resolved_targets: )
descriptor = self.registry.get_agent("local-subagent") result = await self._execute_descriptor(
if descriptor and self._descriptor_allowed(descriptor): descriptor,
resolved_targets = [descriptor.id] task,
if not resolved_targets: label,
raise ValueError("No agents available for group delegation") skill_names=skills,
resolved_targets = list(dict.fromkeys(resolved_targets)) event_callback=self._build_progress_callback(
origin,
descriptors: list[AgentDescriptor] = [] descriptor,
missing: list[str] = [] event_run_id=child_run_id,
for item in resolved_targets: tracking_run_id=parent_run_id,
descriptor = self.registry.get_agent(item) publish_via_bus=announce_via_bus,
if descriptor is None: ),
missing.append(item) task_callback=self._build_task_callback(parent_run_id, descriptor),
else: process_run_id=child_run_id,
self._ensure_descriptor_allowed(descriptor) )
descriptors.append(descriptor) await self._emit_agent_finished(child_run_id, descriptor, result)
if missing: return result
raise ValueError(f"Agent(s) not found: {', '.join(missing)}") except asyncio.CancelledError:
await self._emit_agent_cancelled(child_run_id, descriptor, label)
semaphore = asyncio.Semaphore(self.max_parallel_agents) raise
except Exception as exc:
async def _run_one(descriptor: AgentDescriptor) -> AgentRunResult: result = AgentRunResult(
# group 内每个成员都分配独立 child run_id便于前端区分子树。 agent_id=descriptor.id,
child_run_id = new_run_id("agent") agent_name=descriptor.name,
async with semaphore: status="error",
try: summary=f"Error: {exc}",
await self._emit_agent_started( )
child_run_id, await self._emit_agent_finished(child_run_id, descriptor, result)
descriptor, return result
label,
parent_run_id=run_id,
task=task,
)
result = await self._execute_descriptor(
descriptor,
task,
label,
skill_names=skills,
event_callback=self._build_progress_callback(
origin,
descriptor,
event_run_id=child_run_id,
tracking_run_id=run_id,
publish_via_bus=announce_via_bus,
),
task_callback=self._build_task_callback(run_id, descriptor),
process_run_id=child_run_id,
)
await self._emit_agent_finished(child_run_id, descriptor, result)
return result
except asyncio.CancelledError:
await self._emit_agent_cancelled(child_run_id, descriptor, label)
raise
except Exception as exc:
result = AgentRunResult(
agent_id=descriptor.id,
agent_name=descriptor.name,
status="error",
summary=f"Error: {exc}",
)
await self._emit_agent_finished(child_run_id, descriptor, result)
return result
results = await asyncio.gather(*[_run_one(agent) for agent in descriptors])
return results
async def _execute_descriptor( async def _execute_descriptor(
self, self,
@ -1164,52 +1313,102 @@ class DelegationManager:
) )
logger.debug("Delegation [{}] announced result", run_id) logger.debug("Delegation [{}] announced result", run_id)
async def _announce_group_result( async def _announce_orchestrator_result(
self, self,
run_id: str, run_id: str,
label: str, label: str,
task: str, task: str,
results: list[AgentRunResult], result: BridgeResult,
origin: dict[str, str], origin: dict[str, str],
*, *,
announce_via_bus: bool, announce_via_bus: bool,
) -> None: ) -> None:
"""公告 agent team 汇总结果。""" """公告 orchestrator 驱动的 agent team 结果。
lines = [f"[Agent team '{label}' completed]", "", f"Task: {task}", "", "Members:"]
for result in results: Demo 输出:
lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}") `[Agent team 'weekly report' completed]\nExecution mode: swarms\nMatched procedure: procedure-a1b2c3d4`
lines.extend(["", "Results:"]) """
for result in results: # 这里显式保留 mode / procedure 信息,方便主 agent 做更准确的用户总结。
lines.append(f"### {result.agent_name} ({result.status})") await self._emit_team_progress(
lines.append(result.summary) run_id,
lines.append("") "Preparing orchestrated agent-team summary for the main agent.",
lines.append( stage_label="整理团队结果",
"Summarize this naturally for the user. Mention disagreements or failures if any." metadata={
"phase": "announcement",
"step": "build_orchestrator_summary",
"execution_mode": result.mode.value,
"attempt_count": len(result.attempts),
},
) )
status_text = "completed" if result.success else "failed"
lines = [
f"[Agent team '{label}' {status_text}]",
"",
f"Task: {task}",
f"Execution mode: {result.mode.value}",
]
if result.matched_procedure is not None:
lines.append(
"Matched procedure: "
f"{result.matched_procedure.id} "
f"(confidence={result.matched_procedure.confidence:.2f})"
)
if result.attempts:
lines.extend(["", "Attempts:"])
for attempt in result.attempts:
attempt_status = "ok" if attempt.success else "error"
lines.append(f"- {attempt.mode.value}: {attempt_status}")
if attempt.error:
lines.append(f" error: {attempt.error}")
member_results = result.last_member_results()
if member_results:
lines.extend(["", "Members:"])
for item in member_results:
lines.append(f"- {item.agent_name} ({item.agent_id}): {item.status}")
lines.extend(["", "Results:"])
for item in member_results:
lines.append(f"### {item.agent_name} ({item.status})")
lines.append(item.summary)
lines.append("")
lines.extend([
"Final summary:",
result.summary,
"",
"Summarize this naturally for the user. Mention disagreements or failures if any.",
])
summary = "\n".join(lines).strip() summary = "\n".join(lines).strip()
if announce_via_bus: if announce_via_bus:
await self._publish_announcement( await self._publish_announcement(
summary, summary,
origin, origin,
sender_id="delegation-group", sender_id="delegation-team",
run_id=run_id,
category="agent_team_orchestrated",
) )
else: else:
await self._notify_direct_announcement( await self._notify_direct_announcement(
summary, summary,
origin, origin,
"delegation-group", "delegation-team",
run_id=run_id,
category="agent_team_orchestrated",
) )
await self._emit_direct_user_message( await self._emit_direct_user_message(
summary, summary,
"Agent team 已完成,请查看各 agent 的结果与最终结论。", "Agent team 已完成,请查看最终结论与各次尝试摘要",
) )
logger.debug("Agent team [{}] announced result", run_id) logger.debug("Agent team [{}] announced orchestrated result", run_id)
async def _publish_announcement( async def _publish_announcement(
self, self,
content: str, content: str,
origin: dict[str, str], origin: dict[str, str],
sender_id: str, sender_id: str,
*,
run_id: str | None = None,
category: str | None = None,
) -> None: ) -> None:
"""通过 system inbound 消息把公告重新送回主 agent 链路。""" """通过 system inbound 消息把公告重新送回主 agent 链路。"""
msg = InboundMessage( msg = InboundMessage(
@ -1219,3 +1418,18 @@ class DelegationManager:
content=content, content=content,
) )
await self.bus.publish_inbound(msg) await self.bus.publish_inbound(msg)
if run_id:
await self._emit_team_progress(
run_id,
"Team summary has been published back to the main agent via the system bus.",
stage_label="团队结果已回流",
metadata={
"phase": "announcement",
"step": "bus_publish_complete",
"announcement_path": "bus",
"announcement_sender_id": sender_id,
"announcement_category": category,
"origin_channel": origin.get("channel"),
"origin_chat_id": origin.get("chat_id"),
},
)

View File

@ -83,6 +83,7 @@ class AgentLoop:
allow_local_delegation: bool = True, allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True, allow_plugin_delegation: bool = True,
include_plugin_agents: bool = True, include_plugin_agents: bool = True,
gateway_port: int = 18790,
): ):
from nanobot.config.schema import A2AConfig, ExecToolConfig from nanobot.config.schema import A2AConfig, ExecToolConfig
# 基础依赖与运行参数。 # 基础依赖与运行参数。
@ -142,6 +143,7 @@ class AgentLoop:
) )
self.delegation = DelegationManager( self.delegation = DelegationManager(
provider=provider, provider=provider,
model=self.model,
workspace=workspace, workspace=workspace,
bus=bus, bus=bus,
registry=self.agent_registry, registry=self.agent_registry,
@ -157,6 +159,7 @@ class AgentLoop:
allow_local_delegation=self.allow_local_delegation, allow_local_delegation=self.allow_local_delegation,
allow_plugin_delegation=self.allow_plugin_delegation, allow_plugin_delegation=self.allow_plugin_delegation,
allow_local_fallback=self.include_local_fallback, allow_local_fallback=self.include_local_fallback,
gateway_port=gateway_port,
) )
self.subagents.set_nested_delegate(self.delegation) self.subagents.set_nested_delegate(self.delegation)

View File

@ -6,6 +6,42 @@ from dataclasses import dataclass
from typing import Any from typing import Any
_PLACEHOLDER_SUMMARY_MARKERS = (
"task completed but no final response was generated",
"no final response was generated",
"已启动代理团队",
"代理团队正在后台工作",
"agent team [",
"spawn_agent_team",
"error calling llm",
"litellm.timeout",
"dashscopeexception",
"service temporarily unavailable",
"planner调用失败",
"本任务当前不可执行",
"无法由单一非sop工具完成",
)
def normalize_summary_text(text: str | None) -> str:
"""把摘要文本压成便于判定的稳定形式。"""
return " ".join(str(text or "").strip().split())
def contains_placeholder_summary(text: str | None) -> bool:
"""判断摘要是否只是占位兜底文本。"""
normalized = normalize_summary_text(text).lower()
if not normalized:
return True
return any(marker in normalized for marker in _PLACEHOLDER_SUMMARY_MARKERS)
def has_meaningful_summary(text: str | None) -> bool:
"""判断摘要是否包含可复用的真实结果。"""
normalized = normalize_summary_text(text)
return bool(normalized) and not contains_placeholder_summary(normalized)
@dataclass @dataclass
class AgentRunResult: class AgentRunResult:
"""统一描述一次 agent 执行结果。""" """统一描述一次 agent 执行结果。"""

View File

@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.run_result import AgentRunResult from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
@ -147,15 +147,24 @@ class SubagentManager:
final_result = response.content final_result = response.content
break break
if final_result is None: status = "ok"
# 兜底避免出现“任务做完了但完全没文本”的空结果。 raw: dict[str, Any] | None = None
if not has_meaningful_summary(final_result):
# 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败,
# 防止上层把这类占位结果学习成 procedure。
final_result = "Task completed but no final response was generated." final_result = "Task completed but no final response was generated."
status = "error"
raw = {
"reason": "no_final_response_generated",
"iterations": iteration,
}
return AgentRunResult( return AgentRunResult(
agent_id=agent_id, agent_id=agent_id,
agent_name=agent_name, agent_name=agent_name,
status="ok", status=status,
summary=final_result, summary=final_result,
raw=raw,
) )
def _build_local_tools( def _build_local_tools(

View File

@ -174,7 +174,6 @@ class LocalSubagentStore:
"local_subagent": True, "local_subagent": True,
}, },
"capabilities": {"streaming": False}, "capabilities": {"streaming": False},
"support_group": False,
"support_streaming": False, "support_streaming": False,
} }

View File

@ -0,0 +1,63 @@
"""Agent Team swarms adapter package."""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"AgentTeamOrchestrator",
"BridgeAttempt",
"BridgeResult",
"ExecutionMode",
"NanobotAgentAdapter",
"ProcedureMemory",
"ProcedureRecord",
"ResolvedTeamPlan",
"RunMemory",
"RunRecord",
"SwarmsBridge",
"SwarmsPolicy",
"SwarmsRunPlanner",
"SwarmsRunResult",
"SwarmsRunSpec",
]
def __getattr__(name: str) -> Any:
if name == "AgentTeamOrchestrator":
from nanobot.agent_team.orchestrator import AgentTeamOrchestrator
return AgentTeamOrchestrator
if name == "NanobotAgentAdapter":
from nanobot.agent_team.swarms_adapter import NanobotAgentAdapter
return NanobotAgentAdapter
if name == "SwarmsBridge":
from nanobot.agent_team.swarms_bridge import SwarmsBridge
return SwarmsBridge
if name == "SwarmsPolicy":
from nanobot.agent_team.swarms_policy import SwarmsPolicy
return SwarmsPolicy
if name == "SwarmsRunPlanner":
from nanobot.agent_team.swarms_planner import SwarmsRunPlanner
return SwarmsRunPlanner
if name in {"ProcedureMemory", "RunMemory"}:
memory = import_module("nanobot.agent_team.memory")
return getattr(memory, name)
if name in {
"BridgeAttempt",
"BridgeResult",
"ExecutionMode",
"ProcedureRecord",
"ResolvedTeamPlan",
"RunRecord",
"SwarmsRunResult",
"SwarmsRunSpec",
}:
types = import_module("nanobot.agent_team.types")
return getattr(types, name)
raise AttributeError(name)

View File

@ -0,0 +1,361 @@
"""Agent Team 的轻量持久化层。
这里没有引入数据库,
而是参考轻量 file store 设计:
1. 数据结构尽量稳定;
2. 使用原子写覆盖,避免半写状态;
3. 单文件规模保持小而可读,便于排查与测试。
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from nanobot.agent.run_result import contains_placeholder_summary, has_meaningful_summary
from nanobot.agent_team.types import (
BridgeResult,
ExecutionMode,
ProcedureRecord,
RunRecord,
now_iso,
)
# ASCII token 用于英文/agent id/命令片段匹配。
_ASCII_TOKEN_RE = re.compile(r"[a-z0-9_:-]+")
# 中文任务没有自然空格,这里退而求其次按单字切分,保证最小可匹配能力。
_CJK_CHAR_RE = re.compile(r"[\u4e00-\u9fff]")
def _memory_root(workspace: Path) -> Path:
"""返回 agent team memory 根目录。
Demo 输出:
`/workspace/agent_team`
"""
# 独立目录便于用户直接查看 procedure/runs 文件,不和其他 runtime 状态混在一起。
root = workspace / "agent_team"
root.mkdir(parents=True, exist_ok=True)
return root
def _load_json(path: Path, default: Any) -> Any:
"""从磁盘加载 JSON损坏或不存在时回退到默认值。
Demo 输出:
`[]`
"""
# agent team memory 不应因为单个文件损坏就拖垮主链路,所以统一做软失败。
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return default
def _atomic_write_json(path: Path, payload: Any) -> None:
"""把 JSON 原子写入目标路径。
Demo 输出:
`None`
"""
# 先写临时文件再 `os.replace`,这样即使进程中断也不会留下半截 JSON。
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
os.replace(str(tmp_path), str(path))
def task_tokens(text: str) -> list[str]:
"""把任务文本压成可匹配的轻量 token 列表。
Demo 输出:
`["生成", "周报", "writer-agent", "publish"]`
"""
# 统一小写,保证 agent id、英文命令和 task keywords 比较时大小写无关。
lowered = (text or "").strip().lower()
if not lowered:
return []
# 英文 token 适合匹配 agent id、命令词和常见英文任务描述。
ascii_tokens = [token for token in _ASCII_TOKEN_RE.findall(lowered) if len(token) > 1]
# 中文这里按单字匹配,虽然粗糙,但比整句更利于无分词依赖的第一版实现。
cjk_tokens = _CJK_CHAR_RE.findall(lowered)
# 用 `dict.fromkeys` 去重并保持原始顺序,便于后续测试断言更稳定。
return list(dict.fromkeys([*ascii_tokens, *cjk_tokens]))
def similarity_score(query_tokens: list[str], candidate_tokens: list[str]) -> float:
"""按 token 重叠度计算相似度。
Demo 输出:
`0.67`
"""
# 任一侧为空都说明没有稳定的匹配依据,直接给 0。
if not query_tokens or not candidate_tokens:
return 0.0
# 这里故意不做复杂权重,保持算法透明、可预测、可测试。
query_set = set(query_tokens)
candidate_set = set(candidate_tokens)
overlap = len(query_set & candidate_set)
if overlap <= 0:
return 0.0
# 使用 `max(len(query), len(candidate))` 作为分母,让长任务模板不会被短查询轻易误命中。
return overlap / max(len(query_set), len(candidate_set))
def clip_confidence(value: float) -> float:
"""把置信度裁剪到 `[0.0, 1.0]`。
Demo 输出:
`0.8`
"""
# 所有 confidence 更新都收口到这里,避免散落的边界处理不一致。
return max(0.0, min(1.0, round(value, 4)))
class ProcedureMemory:
"""管理 learned procedure 的持久化和匹配。
公开方法都带了 Demo 输出说明,便于用户直接对照磁盘结果和测试脚本理解行为。
"""
def __init__(
self,
workspace: Path,
*,
min_confidence: float = 0.55,
match_threshold: float = 0.2,
) -> None:
"""初始化 procedure memory。
Demo 输出:
`ProcedureMemory(workspace=/tmp/demo-workspace, procedures.json ready)`
"""
# `procedures.json` 用数组存储,人工排查时最直观。
self.workspace = workspace
self.path = _memory_root(workspace) / "procedures.json"
# 低于该值的 procedure 即使匹配到关键词,也不建议作为复用提示。
self.min_confidence = min_confidence
# 匹配阈值保持较低,只作为 AutoSwarmBuilder / planner 的参考提示。
self.match_threshold = match_threshold
def list_procedures(self) -> list[ProcedureRecord]:
"""读取全部 procedure 记录并按置信度排序。
Demo 输出:
`[ProcedureRecord(...), ProcedureRecord(...)]`
"""
# 文件损坏或不存在时直接回空列表,主流程会自动退回探索模式。
raw = _load_json(self.path, [])
records = [
ProcedureRecord.from_dict(item)
for item in raw
if isinstance(item, dict)
]
# 高置信度、最近更新的记录更靠前,方便测试和人工查看。
records.sort(key=lambda item: (item.confidence, item.updated_at), reverse=True)
return records
def match_procedure(self, task: str) -> ProcedureRecord | None:
"""为当前任务匹配最合适的 procedure。
Demo 输出:
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)`
"""
# 没有 token 说明任务文本几乎为空,此时不应命中任何 procedure。
query_tokens = task_tokens(task)
if not query_tokens:
return None
best_record: ProcedureRecord | None = None
best_score = 0.0
for record in self.list_procedures():
# 明显是占位/空结果的历史 procedure 直接忽略,避免污染后续路由。
if contains_placeholder_summary(record.summary):
continue
# 优先用关键词匹配;任务模板是人工兜底线索。
candidate_tokens = record.task_keywords or task_tokens(record.task_template)
score = similarity_score(query_tokens, candidate_tokens)
# task_template 全量包含时,给一个小额加分,提高近似重跑命中率。
if record.task_template and record.task_template.lower() in task.lower():
score += 0.1
# 最终排序同时考虑相似度、置信度和失败率,避免高失败 procedure 反复被选中。
weighted = score + record.confidence * 0.2 - record.failure_rate() * 0.2
if weighted > best_score:
best_record = record
best_score = weighted
# 分数不足则视为没有可靠命中,让上层走探索式执行。
if best_record is None or best_score < self.match_threshold:
return None
return best_record
async def record_candidate(self, task: str, result: BridgeResult) -> ProcedureRecord | None:
"""把探索阶段产出的候选 procedure 写入 memory。
Demo 输出:
`ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.6, success_count=1, ...)`
"""
# 只有 bridge 显式产出候选 procedure 时才会落盘。
candidate = result.candidate_procedure
if candidate is None:
return None
if not has_meaningful_summary(candidate.summary):
return None
# 记录写入时间统一在这里刷新,保证磁盘上的排序行为可预测。
timestamp = now_iso()
# 任务 token 统一在持久化层补齐,保证不依赖具体 bridge 的实现细节。
merged_keywords = list(dict.fromkeys([*candidate.task_keywords, *task_tokens(task)]))
candidate.task_keywords = merged_keywords
candidate.task_template = candidate.task_template or task
candidate.summary = candidate.summary or result.summary
candidate.confidence = clip_confidence(candidate.confidence or 0.55)
candidate.created_at = candidate.created_at or timestamp
candidate.updated_at = timestamp
records = self.list_procedures()
best_index: int | None = None
best_score = 0.0
for index, record in enumerate(records):
# 完全相同 agent 组合视为强相关;否则退回关键词重叠比对。
same_agents = (
record.strategy == candidate.strategy
and record.agent_ids == candidate.agent_ids
)
score = 1.0 if same_agents else similarity_score(candidate.task_keywords, record.task_keywords)
if score > best_score:
best_index = index
best_score = score
if best_index is not None and best_score >= 0.5:
# 合并已有记录,避免每次探索都生成一条几乎重复的 procedure。
current = records[best_index]
current.task_template = candidate.task_template or current.task_template
current.summary = candidate.summary or current.summary
current.agent_ids = list(candidate.agent_ids) or current.agent_ids
current.strategy = candidate.strategy or current.strategy
current.task_keywords = list(dict.fromkeys([*current.task_keywords, *candidate.task_keywords]))
current.confidence = clip_confidence(max(current.confidence, candidate.confidence))
current.success_count += 1
current.updated_at = timestamp
current.metadata.update(candidate.metadata)
current.source_run_id = candidate.source_run_id or current.source_run_id
stored = current
else:
# 新候选第一次入库时直接记为一次成功学习。
candidate.success_count = max(candidate.success_count, 1)
candidate.failure_count = max(candidate.failure_count, 0)
candidate.created_at = candidate.created_at or timestamp
candidate.updated_at = timestamp
records.append(candidate)
stored = candidate
_atomic_write_json(self.path, [item.to_dict() for item in records])
return stored
async def update_confidence(self, procedure_id: str, delta: float) -> ProcedureRecord | None:
"""更新某条 procedure 的置信度与成败计数。
Demo 输出:
`ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.75, success_count=2, failure_count=0, ...)`
"""
# 没有主键时直接回空,避免误更新所有记录。
if not procedure_id:
return None
records = self.list_procedures()
updated: ProcedureRecord | None = None
for record in records:
if record.id != procedure_id:
continue
# 所有状态变更都集中在这里,保证计数和 confidence 始终同步。
record.confidence = clip_confidence(record.confidence + delta)
# 统一刷新“最近一次使用”和“最近一次更新时间”,这两个字段都服务于路由与排障。
timestamp = now_iso()
record.updated_at = timestamp
record.last_used_at = timestamp
if delta >= 0:
record.success_count += 1
else:
record.failure_count += 1
updated = record
break
if updated is None:
return None
_atomic_write_json(self.path, [item.to_dict() for item in records])
return updated
class RunMemory:
"""管理 run 级别的历史记录。"""
def __init__(self, workspace: Path, *, max_records: int = 200) -> None:
"""初始化 run memory。
Demo 输出:
`RunMemory(workspace=/tmp/demo-workspace, runs.json ready)`
"""
# `runs.json` 保持轻量滚动窗口,避免长期运行后无限膨胀。
self.workspace = workspace
self.path = _memory_root(workspace) / "runs.json"
self.max_records = max(1, max_records)
def list_runs(self) -> list[RunRecord]:
"""读取全部 run 记录。
Demo 输出:
`[RunRecord(...), RunRecord(...)]`
"""
raw = _load_json(self.path, [])
return [
RunRecord.from_dict(item)
for item in raw
if isinstance(item, dict)
]
async def record_run(
self,
task: str,
mode: ExecutionMode,
result: BridgeResult,
procedure_id: str | None = None,
) -> RunRecord:
"""把一次 agent team 运行结果落盘。
Demo 输出:
`RunRecord(id='run-1a2b3c4d', mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, ...)`
"""
# 把 attempt/原始 bridge 结果也带进 metadata后面排查 swarms 执行很有用。
record = RunRecord(
task=task,
mode=mode,
success=result.success,
summary=result.summary,
error=result.error,
procedure_id=procedure_id or (result.matched_procedure.id if result.matched_procedure else None),
metadata={
"attempts": [attempt.to_dict() for attempt in result.attempts],
"bridge_result": result.to_dict(),
},
)
runs = self.list_runs()
runs.append(record)
# 只保留最近 N 条,保证 JSON 文件体积可控。
if len(runs) > self.max_records:
runs = runs[-self.max_records:]
_atomic_write_json(self.path, [item.to_dict() for item in runs])
return record

View File

@ -0,0 +1,241 @@
"""Thin swarms orchestrator for `spawn_agent_team`."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.agent.agent_registry import AgentRegistry
from nanobot.agent.process_events import emit_process_event
from nanobot.agent_team.memory import ProcedureMemory, RunMemory
from nanobot.agent_team.swarms_adapter import MemberRunner
from nanobot.agent_team.swarms_bridge import SwarmsBridge
from nanobot.agent_team.swarms_planner import SwarmsRunPlanner
from nanobot.agent_team.swarms_policy import SwarmsPolicy
from nanobot.agent_team.target_resolver import TargetResolver
from nanobot.agent_team.types import BridgeResult, ExecutionMode
from nanobot.providers.base import LLMProvider
class AgentTeamOrchestrator:
"""Plan a swarms run, execute it, and persist the normalized result."""
def __init__(
self,
*,
workspace: Path,
provider: LLMProvider,
model: str | None,
registry: AgentRegistry,
bus: Any,
local_executor: Any,
member_runner: MemberRunner,
max_parallel_agents: int = 4,
gateway_port: int = 18790,
) -> None:
self.workspace = workspace
self.registry = registry
self.bus = bus
self.local_executor = local_executor
self.procedure_memory = ProcedureMemory(workspace)
self.run_memory = RunMemory(workspace)
self.policy = SwarmsPolicy(max_agents=max_parallel_agents)
self.target_resolver = TargetResolver(
workspace=workspace,
registry=registry,
provider=provider,
model=model,
max_parallel_agents=max_parallel_agents,
gateway_port=gateway_port,
)
self.planner = SwarmsRunPlanner(
model=model,
registry=registry,
target_resolver=self.target_resolver,
procedure_memory=self.procedure_memory,
policy=self.policy,
)
self.swarms = SwarmsBridge(
workspace=workspace,
registry=registry,
member_runner=member_runner,
)
@staticmethod
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in metadata.items()
if value is not None
and not (isinstance(value, str) and not value.strip())
and not (isinstance(value, (list, tuple, set, dict)) and not value)
}
async def _emit_trace(
self,
run_id: str,
text: str,
*,
stage_label: str,
metadata: dict[str, Any] | None = None,
) -> None:
await emit_process_event(
"process_run_progress",
run_id=run_id,
actor_type="system",
actor_id="agent-team",
actor_name="Agent Team",
text=text,
metadata=self._clean_metadata({
"source": "agent_team_orchestrator",
"stage_label": stage_label,
**(metadata or {}),
}),
)
async def run_task(
self,
*,
task: str,
label: str,
skills: list[str],
origin: dict[str, str],
announce_via_bus: bool,
run_id: str,
) -> BridgeResult:
"""Run the team task through swarms only."""
await self._emit_trace(
run_id,
"Preparing a swarms run specification for the agent team.",
stage_label="准备 swarms 运行规格",
metadata={
"phase": "planning",
"skills": list(skills),
"origin": dict(origin),
"announce_via_bus": announce_via_bus,
},
)
spec = await self.planner.plan(task=task, label=label, skills=list(skills))
await self._emit_trace(
run_id,
f"Swarms run spec is ready: {spec.swarm_type} with {len(spec.agent_ids)} agent(s).",
stage_label="swarms 运行规格已就绪",
metadata={
"phase": "planning",
"spec": spec.to_dict(),
},
)
logger.info(
"Agent team [{}] running swarms type={} agents={}",
run_id,
spec.swarm_type,
spec.agent_ids,
)
cleanup: dict[str, Any] = {}
try:
result = await self.swarms.run_spec(spec=spec, run_id=run_id)
finally:
cleanup = await self._cleanup_created_specialists(spec, run_id)
if cleanup:
result.raw.setdefault("provisioning_cleanup", cleanup)
if cleanup.get("created_targets"):
# The run used temporary specialists that have now been removed; do not
# persist a reusable procedure pointing at deleted agent ids.
result.candidate_procedure = None
result.raw.setdefault("origin", dict(origin))
result.raw.setdefault("announce_via_bus", announce_via_bus)
stored_procedure = None
if result.success:
stored_procedure = await self.procedure_memory.record_candidate(task, result)
await self.run_memory.record_run(
task,
ExecutionMode.SWARMS,
result,
procedure_id=(
stored_procedure.id
if stored_procedure is not None
else (
result.matched_procedure.id
if result.matched_procedure is not None
else None
)
),
)
await self._emit_trace(
run_id,
"Swarms agent team run completed.",
stage_label="swarms 团队执行完成",
metadata={
"phase": "completed",
"success": result.success,
"mode": result.mode.value,
"stored_procedure_id": stored_procedure.id if stored_procedure else None,
"attempt_count": len(result.attempts),
},
)
return result
async def _cleanup_created_specialists(
self,
spec: Any,
run_id: str,
) -> dict[str, Any]:
created_targets = self._created_provisioned_targets(spec)
if not created_targets:
return {}
error = None
try:
deleted_targets = self.target_resolver.provisioning.cleanup_local_specialists(created_targets)
except Exception as exc:
deleted_targets = []
error = str(exc)
logger.warning("Failed to clean up auto-provisioned agent-team specialists: {}", exc)
deleted_set = set(deleted_targets)
cleanup = {
"created_targets": created_targets,
"deleted_targets": deleted_targets,
"skipped_targets": [
target
for target in created_targets
if target not in deleted_set
],
}
if error is not None:
cleanup["error"] = error
try:
await self._emit_trace(
run_id,
"Cleaned up auto-provisioned agent-team specialists.",
stage_label="清理自动创建的团队成员",
metadata={
"phase": "cleanup",
**cleanup,
},
)
except Exception as exc:
logger.warning("Failed to emit agent-team cleanup trace: {}", exc)
return cleanup
@staticmethod
def _created_provisioned_targets(spec: Any) -> list[str]:
metadata = getattr(spec, "metadata", {})
if not isinstance(metadata, dict):
return []
target_plan = metadata.get("target_plan")
if not isinstance(target_plan, dict):
return []
created_targets = target_plan.get("created_provisioned_targets")
if not created_targets:
plan_metadata = target_plan.get("metadata")
if isinstance(plan_metadata, dict):
created_targets = plan_metadata.get("created_provisioned_targets")
return [
target
for target in dict.fromkeys(str(item).strip() for item in (created_targets or []))
if target
]

View File

@ -0,0 +1,185 @@
"""Provision managed local A2A specialists for agent teams."""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.agent.subagents import LocalSubagentStore, normalize_subagent_id
from nanobot.config.schema import Config
@dataclass(frozen=True)
class SpecialistProvisionResult:
"""Result of ensuring a managed specialist exists."""
agent_id: str
created: bool
class ProvisioningManager:
"""Manage local specialists through LocalSubagentStore."""
def __init__(self, workspace: Path, *, gateway_port: int = 18790) -> None:
self.workspace = workspace
self.gateway_port = int(os.getenv("APP_BACKEND_PORT") or gateway_port)
self.store = LocalSubagentStore(workspace)
async def ensure_local_specialist_with_result(
self,
*,
role: str,
task: str,
skills: list[str] | None = None,
) -> SpecialistProvisionResult:
"""创建或刷新一个本地 specialist并返回它是否是首次创建。"""
# role 可能来自上游 planner、用户输入或其他动态流程这里先做兜底和规范化
# 1. 空值时退回到通用角色 "general specialist"
# 2. 去掉首尾空白,避免生成不稳定的 agent 标识
# 这样可以保证后续 id、显示名、标签等字段都基于同一个干净的角色名生成。
role_name = str(role or "general specialist").strip() or "general specialist"
# agent_id 由“角色名 + 任务指纹”组成:
# - 同一角色处理同一任务时会命中同一个 id从而实现刷新/复用
# - 同一角色处理不同任务时会得到不同 id避免不同任务上下文互相污染
agent_id = self._specialist_id(role_name, task)
# display_name 主要用于人类可读展示;它不影响真正的唯一性,
# 唯一性仍由 agent_id 保证。
display_name = self._display_name(role_name)
# 为即将 upsert 的 subagent 构造运行时配置。
# 这里显式覆盖两个关键字段:
# - workspace确保 specialist 和当前 agent team 运行在同一个工作目录
# - gateway.port确保它连接到当前后端实例暴露的网关端口
# 这样新建/刷新出来的本地 specialist 才能在正确的环境里工作。
config = Config()
config.agents.defaults.workspace = str(self.workspace)
config.gateway.port = self.gateway_port
# payload 是写入 LocalSubagentStore 的完整声明式规格。
# store.upsert_subagent(...) 会根据这份规格创建或刷新 subagent。
payload = {
# 稳定唯一 id用于判断“是否已存在”以及后续更新同一个 specialist。
"id": agent_id,
# 人类可读名称,便于在 UI、日志或调试信息中识别角色。
"name": display_name,
# 简短描述说明该 agent 的来源和用途:它是 agent team 自动托管的本地 A2A specialist。
"description": f"Managed local A2A specialist for {role_name}.",
# system_prompt 注入角色视角、原始任务以及本次要求携带的技能上下文,
# 是 specialist 实际行为边界和任务目标的核心输入。
"system_prompt": self._system_prompt(role_name, task, skills or []),
# 允许它进行完整委派;也就是说该 specialist 自己可以继续向下分派任务,
# 而不是被限制为只能本地直接回答。
"delegation_mode": "full",
# 允许访问 MCP表示这个 specialist 在受外层权限控制的前提下可以使用 MCP 能力。
"allow_mcp": True,
# tags 用于分类、筛选和后续清理:
# - auto-provisioned / agent-team标明它是系统自动创建的团队成员
# - role_name.replace(" ", "-"):保留一个角色维度标签,便于检索
# - skills把本次技能要求也落到标签中方便观测和调试
# 使用 set 去重、sorted 排序,保证结果稳定。
"tags": sorted(set(["auto-provisioned", "agent-team", role_name.replace(" ", "-")] + list(skills or []))),
# aliases 提供额外可匹配名称,既支持原始角色名,也支持格式化后的展示名。
"aliases": [role_name, display_name],
# metadata 存放程序消费的结构化信息:
# - managed_by标记由哪个模块托管后续 cleanup 时会用来判定是否允许删除
# - role记录规范化后的角色名
# - task_fingerprint记录任务指纹便于追踪这个 specialist 绑定的是哪类任务上下文
"metadata": {
"managed_by": "agent_team_provisioning",
"role": role_name,
"task_fingerprint": self._fingerprint(task),
},
}
# 先读取一次已有记录,用于区分“首次创建”还是“刷新已有 specialist”。
# 注意:真正的写入动作由后面的 upsert 完成。
existing = self.store.get_subagent(agent_id)
# upsert 语义是:
# - 不存在则创建
# - 已存在则按新的 payload/config 刷新
# 这样调用方不需要区分 create / update 两条路径。
spec = self.store.upsert_subagent(payload, config)
# 日志区分 provisioned 和 refreshed便于排查
# - 为什么这次新建了一个 specialist
# - 或者为什么只是把旧的配置重新覆盖了一次
if existing is None:
logger.info("Provisioned local A2A specialist {} for role '{}'", spec.id, role_name)
else:
logger.info("Refreshed local A2A specialist {} for role '{}'", spec.id, role_name)
# 返回两类关键信息:
# - agent_id供上游继续引用这个 specialist
# - created明确告知这次是首次创建还是命中了已有对象并完成刷新
return SpecialistProvisionResult(agent_id=spec.id, created=existing is None)
def cleanup_local_specialists(self, agent_ids: list[str]) -> list[str]:
"""Delete managed specialists and return the ids actually removed."""
deleted: list[str] = []
for agent_id in dict.fromkeys(str(item).strip() for item in agent_ids if str(item).strip()):
spec = self.store.get_subagent(agent_id)
if spec is None:
continue
if not self._is_managed_specialist(spec.metadata, spec.tags):
logger.warning("Skipping cleanup for unmanaged local specialist candidate {}", agent_id)
continue
if self.store.delete_subagent(agent_id):
deleted.append(agent_id)
logger.info("Cleaned up local A2A specialist {}", agent_id)
return deleted
@staticmethod
def _is_managed_specialist(metadata: dict[str, Any], tags: list[str]) -> bool:
return (
metadata.get("managed_by") == "agent_team_provisioning"
or "auto-provisioned" in tags
)
def _specialist_id(self, role: str, task: str) -> str:
base = normalize_subagent_id(role)
return normalize_subagent_id(f"{base}-{self._fingerprint(task)}")
@staticmethod
def _fingerprint(task: str) -> str:
return hashlib.sha1(str(task or "").encode("utf-8")).hexdigest()[:8]
@staticmethod
def _display_name(role: str) -> str:
return " ".join(part.capitalize() for part in re.split(r"[\s_-]+", role.strip()) if part)
def _system_prompt(self, role: str, task: str, skills: list[str]) -> str:
# skills 是本次 team run 要求携带的技能上下文;这里仅写入提示词,
# 真正的工具可用性和权限仍由外层 AgentLoop / tool registry 控制。
skills_text = ", ".join(skills) if skills else "none"
role_text = re.sub(r"\s+", " ", str(role or "").strip()) or "general specialist"
# 这里保持一套完全通用的提示模板:
# - 不对具体角色做领域特化
# - 不规定固定输出格式
# - 只强调“按该角色名称隐含的职责边界来贡献结果”
return (
f"你是 nanobot agent team 中的 {role_text}\n\n"
"请围绕这个角色名称所隐含的职责边界处理原始团队任务。根据任务本身选择"
"合适的方法、工具、下游委派方式和输出格式,不要强行套用固定报告模板。"
"你的结果应该便于团队合并成最终答案;如果关键假设、阻塞点或风险会影响"
"结论,请明确指出。\n\n"
f"原始团队任务:\n{task}\n\n"
f"本次要求的技能:\n{skills_text}"
)

View File

@ -0,0 +1,261 @@
# Agent Team 真实运行调用链
更新时间2026-04-08
这份文档用于代码 review。它不再写伪代码流程图而是按当前实现列出从 `spawn_agent_team` 被调用,到 swarms 多 agent 执行,再到结果公告和持久化的真实函数链路。
核心原则:
```text
nanobot 负责入口、registry、权限、skills、事件、memory、BridgeResult。
swarms 负责团队架构运行、agent 间讨论/编排、调用 adapter。
```
## 主调用链
```text
SpawnAgentTeamTool.execute()
作用LLM/tool 层入口,接收 task / label / skills。
-》 DelegationManager.dispatch_agent_team()
作用:把工具调用转换成 agent_team 委派请求,固定 mode="agent_team"、strategy="group"。
-》 DelegationManager._dispatch()
作用:生成 run_id、display_label、origin创建后台 asyncio task立即返回“Agent team started”。
-》 DelegationManager._run_dispatch()
作用:后台真正执行 agent_team 分支;发出团队开始事件,并把任务交给 orchestrator。
-》 AgentTeamOrchestrator.run_task()
作用agent team 薄编排入口;只做 plan -> swarms -> memory不自建 team runtime。
-》 SwarmsRunPlanner.plan()
作用:生成 SwarmsRunSpec决定 swarm_type、agent_ids、skills、rules、max_loops。
-》 SwarmsBridge.run_spec()
作用:发出“启动 swarms runtime”事件执行 swarms并把 swarms 输出转成 BridgeResult。
-》 SwarmsBridge._run_swarms()
作用:把 SwarmsRunSpec.agent_ids 转成 AgentDescriptor再包成 NanobotAgentAdapter。
-》 load_swarms_runtime()
作用:懒加载 vendored third_party/swarms取 AutoSwarmBuilder / SwarmRouter / GroupChat。
-》 swarms.SwarmRouter(...)
作用:创建 swarms 统一路由器,传入 nanobot adapters、swarm_type、rules、max_loops。
-》 SwarmRouter.run(task=...)
作用:交给 swarms 运行对应架构,例如 GroupChat / SequentialWorkflow / ConcurrentWorkflow。
-》 NanobotAgentAdapter.run()
作用swarms 调用每个 agent adapteradapter 把 swarms conversation context 转回 nanobot 成员任务。
-》 DelegationManager._run_team_member_for_swarms()
作用:为该成员创建 child run做权限检查发 agent started/finished 事件。
-》 DelegationManager._execute_descriptor()
作用:真正执行成员 agentlocal_prompt/local_fallback 走 local_executorA2A agent 走 A2AClient。
-》 local_executor.run_local_task() 或 A2AClient.run_task()
作用:成员 agent 产出 AgentRunResult。
-》 NanobotAgentAdapter.run()
作用:收集 AgentRunResult 到 adapter.results并把 summary 返回给 swarms。
-》 SwarmRouter.run(task=...)
作用swarms 收集所有 adapter 响应,返回 raw_output/transcript。
-》 SwarmsBridge._normalize_swarms_output()
作用:优先用 adapter.results 生成可读 SwarmsRunResult.summary并保留 raw_output。
-》 SwarmsBridge.run_spec()
作用:构造 BridgeAttempt、candidate ProcedureRecord、BridgeResult。
-》 AgentTeamOrchestrator.run_task()
作用:成功时 ProcedureMemory.record_candidate(),随后 RunMemory.record_run(),再返回 BridgeResult。
-》 DelegationManager._run_dispatch()
作用:发团队 finished 事件,并调用 _announce_orchestrator_result()。
-》 DelegationManager._announce_orchestrator_result()
作用:把 BridgeResult 组装成给主 agent 的总结消息。
-》 DelegationManager._publish_announcement() 或 _notify_direct_announcement()
作用:通过 bus 回流主 agent或直连回调到本地会话。
-》 DelegationManager._emit_direct_user_message()
作用:如果有 process event sink给 UI 发即时可见完成消息。
```
## Plan 分支
`SwarmsRunPlanner.plan()` 内部有两个分支。
简单/常规任务:
```text
SwarmsRunPlanner.plan()
作用:读取 ProcedureMemory.match_procedure(task),判断不需要 AutoSwarmBuilder。
-》 SwarmsRunPlanner._simple_required_roles()
作用:从 skills 生成角色,例如 implementation specialist / test specialist没有 skills 则用 general specialist / synthesis analyst。
-》 TargetResolver.resolve_team_targets()
作用:根据 task、skills、required_specialists 选择已有 registry agents缺人时调用 provisioning。
-》 AgentRegistry.suggest_agents() / AgentRegistry.get_agent()
作用:从 workspace/plugin/skill/local registry 中查找可执行 agent。
-》 ProvisioningManager.ensure_local_specialist()
作用:缺少合适 agent 时创建 managed local A2A specialist并写入 workspace agent registry。
-》 SwarmsRunSpec(...)
作用:返回默认 GroupChat 运行规格,带 agent_ids、skills、rules、target_plan metadata。
```
复杂/开放任务:
```text
SwarmsRunPlanner.plan()
作用:如果任务较长、命中复杂关键词,或有 ProcedureMemory hint则进入自动建队。
-》 SwarmsRunPlanner._run_auto_swarm_builder()
作用:调用 swarms.AutoSwarmBuilder 生成 router config 建议。
-》 SwarmsRunPlanner._auto_builder_prompt()
作用:把 task、skills、memory_hint 和硬约束写入 AutoSwarmBuilder prompt。
-》 SwarmsPolicy.validate_auto_config()
作用:只允许安全的 swarm_type限制 max_agents/max_loops剥掉 tools、MCP、API key 等越权字段。
-》 SwarmsRunPlanner._roles_from_auto_config()
作用:从 AutoSwarmBuilder 输出提取需要的角色描述。
-》 TargetResolver.resolve_team_targets()
作用:把角色描述映射成 nanobot registry 中真实可执行的 agent_ids。
-》 SwarmsRunPlanner._rearrange_flow()
作用:如果 swarm_type 是 AgentRearrange则用 safe_swarms_name(agent_id) 生成 flow。
-》 SwarmsRunSpec(...)
作用:返回经过 policy 清洗后的 swarms 运行规格。
```
## Swarms 执行链
```text
SwarmsBridge.run_spec()
作用:接收 SwarmsRunSpec发 process_run_progress(stage_label="启动 swarms runtime")。
-》 SwarmsBridge._run_swarms()
作用:解析 spec.agent_ids构造 adapters并实例化 SwarmRouter。
-》 NanobotAgentAdapter.__post_init__()
作用:设置 swarms 可识别的 agent_name/name/__name__/system_prompt。
-》 SwarmsBridge._rules_with_skills()
作用:生成 swarms rules加入“不要新增工具/凭证/外部 endpoint”和 skills 约束。
-》 SwarmsBridge._task_with_skills()
作用:把 spec.task 和 spec.skills 合并成传给 SwarmRouter.run(task=...) 的任务文本。
-》 SwarmRouter.run(task=...)
作用swarms 按 spec.swarm_type 创建并运行实际 swarm。
-》 GroupChat / SequentialWorkflow / ConcurrentWorkflow / AgentRearrange / MixtureOfAgents / HierarchicalSwarm
作用:由 swarms 负责具体多 agent 架构的讨论、顺序、并行、动态流程或层级协作。
-》 NanobotAgentAdapter.run()
作用:当 swarms 需要某个 agent 响应时,调用 nanobot adapter。
-》 SwarmsBridge._normalize_swarms_output()
作用:把 swarms raw_output 和 adapter.results 合并成 SwarmsRunResult。
-》 SwarmsBridge._candidate_procedure()
作用:成功时构造可选 ProcedureRecord供 ProcedureMemory 学习复用。
-》 BridgeResult(...)
作用:统一返回 success、summary、member_results、candidate_procedure、attempts、raw。
```
## 成员执行链
```text
NanobotAgentAdapter.run(task)
作用:接收 swarms 传入的 conversation/task。
-》 NanobotAgentAdapter._task_with_skills()
作用:把 skills 注入成员任务文本,形成 delegated_task。
-》 asyncio.run_coroutine_threadsafe(member_runner(...))
作用:从 swarms 的同步调用线程切回 nanobot 当前事件循环。
-》 DelegationManager._run_team_member_for_swarms(descriptor, task, parent_run_id, skills)
作用:创建 child_run_id保持父子 process tree。
-》 DelegationManager._ensure_descriptor_allowed()
作用:检查 local/plugin/A2A agent 是否允许被委派。
-》 DelegationManager._emit_agent_started()
作用:发出成员开始事件。
-》 DelegationManager._execute_descriptor()
作用:根据 AgentDescriptor.kind / protocol 选择执行方式。
-》 local_executor.run_local_task()
作用:执行 local_prompt / local_fallback agent并传入 skill_context、skill_names、progress_callback。
-》 A2AClient.run_task()
作用:执行远端或本地 gateway 暴露的 A2A agent。
-》 DelegationManager._emit_agent_finished()
作用:发出成员完成事件。
-》 NanobotAgentAdapter.run()
作用:把 AgentRunResult 存入 adapter.results成功时返回 result.summary失败时返回 error 文本给 swarms。
```
## skills 注入链
```text
SpawnAgentTeamTool.execute(skills)
作用:接收工具参数里的 skills。
-》 DelegationManager.dispatch_agent_team(skills=skills)
作用:把 skills 放进后台 dispatch 参数。
-》 DelegationManager._dispatch(skills=skills)
作用:把 skills 保存到后台 task 调用参数。
-》 DelegationManager._run_dispatch(skills=skills)
作用:把 skills 传给 AgentTeamOrchestrator.run_task()。
-》 AgentTeamOrchestrator.run_task(skills=skills)
作用:把 skills 传给 planner 和 swarms bridge。
-》 SwarmsRunPlanner.plan(skills=skills)
作用skills 参与角色选择和 AutoSwarmBuilder prompt。
-》 SwarmsRunSpec.skills
作用skills 固化到运行规格,供 events、rules、task、adapter 使用。
-》 SwarmsBridge._rules_with_skills()
作用:把 skills 写入 SwarmRouter rules。
-》 SwarmsBridge._task_with_skills()
作用:把 skills 写入 SwarmRouter.run(task=...) 的任务文本。
-》 NanobotAgentAdapter._task_with_skills()
作用:把 skills 写入每个成员看到的 delegated task。
-》 DelegationManager._execute_descriptor(skill_names=skills)
作用:本地 agent 获得 skill_context / skill_namesA2A agent 获得 augment 后的任务文本。
```
## 结果返回链
```text
SwarmsBridge._normalize_swarms_output()
作用:生成 SwarmsRunResult(summary, raw_output, member_results)。
-》 SwarmsBridge.run_spec()
作用:生成 BridgeAttempt 和 BridgeResult。
-》 AgentTeamOrchestrator.run_task()
作用:写 ProcedureMemory 和 RunMemory。
-》 DelegationManager._emit_group_finished()
作用:把团队 run 标记为 done/errormetadata 带 attempts 和成员状态。
-》 DelegationManager._announce_orchestrator_result()
作用:把 BridgeResult 整理成主 agent 可读的系统消息。
-》 DelegationManager._publish_announcement()
作用announce_via_bus=True 时,把消息 publish 到 inbound bus让主 agent 继续总结。
-》 DelegationManager._notify_direct_announcement()
作用announce_via_bus=False 时,直接调用本地回调回流会话。
-》 DelegationManager._emit_direct_user_message()
作用:有 process event sink 时,给前端/UI 发一条即时完成消息。
```
## 当前放行的 swarms 架构
`SwarmsPolicy.allowed_swarm_types` 当前只放行能消费 nanobot adapters 的架构:
```text
GroupChat
SequentialWorkflow
ConcurrentWorkflow
AgentRearrange
MixtureOfAgents
HierarchicalSwarm
```
`GraphWorkflow` / `HeavySwarm` 暂不直接放行,因为当前 vendored `SwarmRouter` 的相关 factory 还不能稳定消费 nanobot 提供的 `NanobotAgentAdapter`、registry、skills 和权限边界。
## 文件职责速查
```text
agent/tools/spawn.py
作用:定义 spawn_agent_team 工具入口。
agent/delegation.py
作用后台调度、process events、成员执行、结果公告。
agent_team/orchestrator.py
作用agent team 主 glue负责 plan -> swarms -> memory。
agent_team/swarms_planner.py
作用:生成 SwarmsRunSpec需要时调用 AutoSwarmBuilder。
agent_team/swarms_policy.py
作用:清洗 AutoSwarmBuilder 输出,限制 swarm_type、agents、loops 和越权字段。
agent_team/target_resolver.py
作用:把角色需求解析成真实 agent_ids。
agent_team/provisioning.py
作用:缺少合适成员时创建 managed local A2A specialist。
agent_team/swarms_adapter.py
作用:懒加载 vendored swarms并把 nanobot agent 包成 swarms 可调用 adapter。
agent_team/swarms_bridge.py
作用:构造 SwarmRouter、运行 swarms、归一化 BridgeResult。
agent_team/memory.py
作用:记录 RunMemory / ProcedureMemory。
agent_team/types.py
作用:定义 SwarmsRunSpec、SwarmsRunResult、BridgeAttempt、BridgeResult 等共享类型。
```

View File

@ -0,0 +1,114 @@
"""Thin adapters between nanobot agents and the vendored swarms runtime."""
from __future__ import annotations
import asyncio
import sys
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from nanobot.agent.agent_registry import AgentDescriptor
from nanobot.agent.run_result import AgentRunResult
MemberRunner = Callable[[AgentDescriptor, str, str, list[str]], Awaitable[AgentRunResult]]
def _candidate_swarms_roots() -> list[Path]:
"""Return likely vendored swarms paths across source and packaged layouts."""
module_path = Path(__file__).resolve()
candidates = [
module_path.parents[2] / "third_party" / "swarms",
Path("/opt/app/backend/third_party/swarms"),
Path("/app/third_party/swarms"),
Path.cwd() / "third_party" / "swarms",
Path.cwd() / "backend" / "third_party" / "swarms",
]
unique: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
unique.append(candidate)
return unique
def ensure_swarms_importable() -> None:
"""Put the vendored swarms checkout on `sys.path` if needed."""
for swarms_root in _candidate_swarms_roots():
if swarms_root.exists() and str(swarms_root) not in sys.path:
sys.path.insert(0, str(swarms_root))
return
def load_swarms_runtime() -> dict[str, Any]:
"""Lazy-load swarms classes without making package import fragile."""
ensure_swarms_importable()
from swarms import AutoSwarmBuilder # type: ignore
from swarms.structs.groupchat import GroupChat # type: ignore
from swarms.structs.swarm_router import SwarmRouter # type: ignore
return {
"AutoSwarmBuilder": AutoSwarmBuilder,
"GroupChat": GroupChat,
"SwarmRouter": SwarmRouter,
}
def __getattr__(name: str) -> Any:
if name in {"AutoSwarmBuilder", "GroupChat", "SwarmRouter"}:
return load_swarms_runtime()[name]
raise AttributeError(name)
def safe_swarms_name(agent_id: str) -> str:
"""Return a GroupChat-friendly ASCII-ish name for @mentions."""
normalized = "".join(ch if ch.isalnum() else "_" for ch in str(agent_id or "agent"))
normalized = normalized.strip("_") or "agent"
return f"agent_{normalized}"
@dataclass(eq=False)
class NanobotAgentAdapter:
"""Callable wrapper that lets swarms invoke a nanobot agent descriptor."""
descriptor: AgentDescriptor
run_id: str
loop: asyncio.AbstractEventLoop
member_runner: MemberRunner
skills: list[str]
results: list[AgentRunResult] = field(default_factory=list, init=False)
def __post_init__(self) -> None:
self.agent_name = safe_swarms_name(self.descriptor.id)
self.name = self.agent_name
self.system_prompt = self.descriptor.system_prompt or self.descriptor.description
self.__name__ = self.agent_name
def __call__(self, conversation_context: str) -> str:
return self.run(conversation_context)
def run(self, task: str, *args: Any, **kwargs: Any) -> str:
delegated_task = self._task_with_skills(task)
future = asyncio.run_coroutine_threadsafe(
self.member_runner(self.descriptor, delegated_task, self.run_id, list(self.skills)),
self.loop,
)
result = future.result(timeout=300)
self.results.append(result)
if result.status != "ok":
return f"Error from {self.agent_name}: {result.summary}"
return result.summary
def _task_with_skills(self, conversation_context: str) -> str:
if not self.skills:
return conversation_context
return (
"Required skills for this delegated team member:\n"
f"{', '.join(self.skills)}\n\n"
"Swarms conversation context:\n"
f"{conversation_context}"
).strip()

View File

@ -0,0 +1,302 @@
"""Bridge from nanobot agent-team tasks into the vendored swarms runtime."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from nanobot.agent.agent_registry import AgentRegistry
from nanobot.agent.process_events import emit_process_event
from nanobot.agent.run_result import has_meaningful_summary
from nanobot.agent_team.swarms_adapter import MemberRunner, NanobotAgentAdapter, load_swarms_runtime
from nanobot.agent_team.types import (
BridgeAttempt,
BridgeResult,
ExecutionMode,
ProcedureRecord,
SwarmsRunResult,
SwarmsRunSpec,
)
class SwarmsBridge:
"""Execute a `SwarmsRunSpec` with `SwarmRouter` and normalize the output."""
def __init__(
self,
*,
workspace: Path,
registry: AgentRegistry,
member_runner: MemberRunner,
) -> None:
self.workspace = workspace
self.registry = registry
self.member_runner = member_runner
async def run_spec(self, *, spec: SwarmsRunSpec, run_id: str) -> BridgeResult:
# 先发一条过程事件告诉上层“swarms 执行阶段已经开始”。
# metadata 里带完整 spec便于前端或日志侧排查本次实际执行参数。
await self._emit_progress(
run_id,
f"Starting swarms run: {spec.swarm_type}.",
stage_label="启动 swarms runtime",
metadata={"spec": spec.to_dict()},
)
# 真正调用 swarms runtime返回的是“桥接层内部使用”的 SwarmsRunResult。
swarms_result = await self._run_swarms(spec=spec, run_id=run_id)
# success 不只看 swarms_result.success还要求 summary 有实际内容。
# 这样可以避免 runtime technically 跑完了,但最终没有任何可消费结论时,
# 上层误把它当成一次成功执行。
success = swarms_result.success and has_meaningful_summary(swarms_result.summary)
error = None if success else (swarms_result.error or swarms_result.summary)
# BridgeAttempt 表示“这次 swarms 模式尝试”的完整快照;
# 后续 BridgeResult.attempts 可以累计不同执行策略/回退路径的尝试记录。
attempt = BridgeAttempt(
mode=ExecutionMode.SWARMS,
success=success,
summary=swarms_result.summary,
error=error,
member_results=list(swarms_result.member_results),
targets=list(spec.agent_ids),
raw={
"spec": spec.to_dict(),
"swarms_result": swarms_result.to_dict(),
},
)
# 只有成功时才生成 candidate procedure避免把失败或空结果学习成可复用流程。
candidate = self._candidate_procedure(spec, swarms_result, run_id) if success else None
# 再发一条归一化完成事件,让编排层知道 bridge 已经把 swarms 原始输出
# 压成了 nanobot 可消费的标准结果结构。
await self._emit_progress(
run_id,
"Swarms run returned a normalized bridge result.",
stage_label="swarms 输出已归一",
metadata={
"success": success,
"swarm_type": spec.swarm_type,
"candidate_procedure_id": candidate.id if candidate else None,
},
)
# BridgeResult 是 swarms bridge 对外暴露的稳定边界:
# - summary/member_results 给上层公告和持久化使用
# - attempts/raw 保留足够多细节,便于后续解释和调试
return BridgeResult(
mode=ExecutionMode.SWARMS,
success=success,
summary=swarms_result.summary,
error=error,
member_results=list(swarms_result.member_results),
candidate_procedure=candidate,
attempts=[attempt],
raw={
"spec": spec.to_dict(),
"swarms_result": swarms_result.to_dict(),
},
)
async def _run_swarms(self, *, spec: SwarmsRunSpec, run_id: str) -> SwarmsRunResult:
try:
# 先把 spec.agent_ids 解析成当前 registry 中的 AgentDescriptor。
# 这里显式校验 agent 必须存在,避免 swarms runtime 在更深处才报模糊错误。
descriptors = []
for agent_id in spec.agent_ids:
descriptor = self.registry.get_agent(agent_id)
if descriptor is None:
raise ValueError(f"Agent not found for swarms run: {agent_id}")
descriptors.append(descriptor)
# swarms runtime 运行在线程池里,但每个 NanobotAgentAdapter 最终仍要把执行
# 切回当前事件循环中的 member_runner因此这里提前拿到 running loop。
loop = asyncio.get_running_loop()
# 把 nanobot 的 AgentDescriptor 包装成 swarms 可以直接调用的 adapter。
# swarms 视角下它们只是“可调用 agent”nanobot 视角下它们会回流到
# member_runner再由本地执行器或 A2A client 真正完成任务。
adapters = [
NanobotAgentAdapter(
descriptor=descriptor,
run_id=run_id,
loop=loop,
member_runner=self.member_runner,
skills=list(spec.skills),
)
for descriptor in descriptors
]
# SwarmRouter 是 vendored swarms runtime 的核心入口。
# 这里把 planner 产出的 swarm_type / loops / flow / rules 全部映射进去。
runtime = load_swarms_runtime()
router = runtime["SwarmRouter"](
name=spec.label or "nanobot-agent-team",
description="Nanobot agent-team swarms router",
agents=adapters,
swarm_type=spec.swarm_type,
max_loops=max(1, spec.max_loops),
rearrange_flow=spec.rearrange_flow,
rules=self._rules_with_skills(spec),
autosave=False,
verbose=False,
)
# swarms 的 router.run 是同步阻塞调用,因此放到线程池中执行,
# 避免阻塞当前 asyncio 事件循环。
raw_output = await asyncio.to_thread(router.run, task=self._task_with_skills(spec))
# swarms 原始输出结构并不稳定,统一在这里归一成 SwarmsRunResult。
return self._normalize_swarms_output(raw_output, adapters)
except Exception as exc:
# 桥接层把异常收口成失败结果,而不是继续向上抛,
# 这样 orchestrator 可以用统一的 BridgeResult 流程处理失败。
return SwarmsRunResult(
success=False,
summary=f"Swarms execution failed: {exc}",
raw_output=None,
error=str(exc),
)
def _rules_with_skills(self, spec: SwarmsRunSpec) -> str:
# 把上层规则和桥接层的硬约束拼到一起:
# 1. 保留 planner 指定的 rules
# 2. 明确禁止 swarms 擅自引入额外 agent、工具或凭证
# 3. 把 skills 也写入规则,确保团队行为不偏离 nanobot 约束
parts = [
spec.rules or "Run the nanobot agent team through swarms and produce a concise synthesis.",
"Do not add tools, credentials, network endpoints, or agents outside the provided nanobot adapters.",
]
if spec.skills:
parts.append("Required nanobot skills: " + ", ".join(spec.skills))
return "\n".join(parts)
def _task_with_skills(self, spec: SwarmsRunSpec) -> str:
# skills 既体现在 rules 中,也直接拼到任务文本里,
# 这样无论 swarms runtime 更依赖哪部分上下文,都能看到技能约束。
if not spec.skills:
return spec.task
return (
f"{spec.task}\n\n"
"Required skills for this swarms run:\n"
f"{', '.join(spec.skills)}"
).strip()
def _normalize_swarms_output(
self,
raw_output: Any,
adapters: list[NanobotAgentAdapter],
) -> SwarmsRunResult:
# 优先从 adapters 收集每个成员真实执行后的 AgentRunResult。
# 这些结果比 swarms runtime 的自由格式输出更稳定、也更适合后续持久化。
member_results = [
result
for adapter in adapters
for result in adapter.results
]
# summary 优先从成员结果推导;如果成员结果拿不到,再从 swarms 原始输出中兜底提取。
summary = self._summary_from_swarms_output(raw_output, member_results)
return SwarmsRunResult(
success=bool(summary.strip()),
summary=summary.strip(),
raw_output=self._jsonable(raw_output),
member_results=member_results,
)
def _summary_from_swarms_output(self, raw_output: Any, member_results: list[Any]) -> str:
# 如果已经拿到了结构化 member_results就优先用它们生成总结
# 因为这比直接依赖 swarms 的原始输出更稳定、更贴近 nanobot 的结果模型。
if member_results:
return "\n\n".join(
f"{result.agent_name} ({result.status}):\n{result.summary}"
for result in member_results
if str(result.summary or "").strip()
)
# swarms 有时直接返回字符串,那就把它当作最终 summary。
if isinstance(raw_output, str):
return raw_output.strip()
# swarms 也可能返回 transcript/list 结构;这里尝试提取非 user/system 的发言,
# 拼成一个可读摘要。
if isinstance(raw_output, list):
lines: list[str] = []
for item in raw_output:
if not isinstance(item, dict):
continue
role = str(item.get("role") or item.get("speaker") or "").strip()
content = str(item.get("content") or item.get("message") or "").strip()
if not content or role.lower() in {"user", "system"}:
continue
lines.append(f"{role}: {content}" if role else content)
if lines:
return "\n\n".join(lines)
# 最后兜底把原始输出尽量序列化成 JSON 文本;再不行就直接 str(...)。
try:
return json.dumps(raw_output, ensure_ascii=False, indent=2)
except TypeError:
return str(raw_output)
def _jsonable(self, value: Any) -> Any:
# raw_output 最终要落到 BridgeResult / RunMemory 里,因此这里尽量保证它可序列化。
# 若原值无法直接 JSON 化,则退回字符串表示,避免整个持久化流程失败。
try:
json.dumps(value, ensure_ascii=False)
return value
except TypeError:
return str(value)
def _candidate_procedure(
self,
spec: SwarmsRunSpec,
result: SwarmsRunResult,
run_id: str,
) -> ProcedureRecord:
# bridge 只负责产出一个“可候选复用”的 procedure 草稿:
# - task_template/agent_ids/strategy 记录执行骨架
# - summary 提供人类可读概览
# - metadata 记录它来自 swarms bridge
# 真正是否持久化、如何更新统计,由更上层的 procedure memory 决定。
return ProcedureRecord(
task_template=spec.task,
summary=result.summary,
agent_ids=list(spec.agent_ids),
strategy=spec.swarm_type,
confidence=0.6,
source_run_id=run_id,
metadata={
"source": "swarms_bridge",
"swarm_type": spec.swarm_type,
"auto_generated": spec.auto_generated,
"skills": list(spec.skills),
},
)
async def _emit_progress(
self,
run_id: str,
text: str,
*,
stage_label: str,
metadata: dict[str, Any] | None = None,
) -> None:
# 统一发 process_run_progress让前端/日志看到 swarms bridge 当前阶段。
await emit_process_event(
"process_run_progress",
run_id=run_id,
actor_type="system",
actor_id="swarms-bridge",
actor_name="Swarms Bridge",
text=text,
metadata={
"source": "swarms_bridge",
"stage_label": stage_label,
**(metadata or {}),
},
)

View File

@ -0,0 +1,184 @@
"""Planner that prepares a minimal swarms run spec for agent-team tasks."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from loguru import logger
from nanobot.agent.agent_registry import AgentRegistry
from nanobot.agent_team.memory import ProcedureMemory
from nanobot.agent_team.swarms_adapter import load_swarms_runtime, safe_swarms_name
from nanobot.agent_team.swarms_policy import SwarmsPolicy
from nanobot.agent_team.target_resolver import TargetResolver
from nanobot.agent_team.types import SwarmsRunSpec
class SwarmsRunPlanner:
"""Generate `SwarmsRunSpec` without rebuilding swarms' own planner/runtime."""
def __init__(
self,
*,
model: str | None,
registry: AgentRegistry,
target_resolver: TargetResolver,
procedure_memory: ProcedureMemory,
policy: SwarmsPolicy,
) -> None:
self.model = model
self.registry = registry
self.target_resolver = target_resolver
self.procedure_memory = procedure_memory
self.policy = policy
async def plan(self, *, task: str, label: str, skills: list[str]) -> SwarmsRunSpec:
memory_hint = self.procedure_memory.match_procedure(task)
if self._should_auto_build(task, skills, memory_hint):
raw_config = await self._run_auto_swarm_builder(task, skills, memory_hint)
return await self._spec_from_auto_config(task, label, skills, raw_config)
target_plan = await self.target_resolver.resolve_team_targets(
task=task,
skills=skills,
required_specialists=self._simple_required_roles(task, skills),
)
return SwarmsRunSpec(
task=task,
label=label,
skills=list(skills),
swarm_type="GroupChat",
agent_ids=list(target_plan.final_targets),
auto_generated=False,
max_loops=2,
rules=self._default_rules(),
metadata={
"memory_hint": memory_hint.id if memory_hint else None,
"target_plan": target_plan.to_dict(),
},
)
def _should_auto_build(self, task: str, skills: list[str], memory_hint: Any) -> bool:
source = task or ""
text = source.lower()
markers = ("架构", "调研", "复杂", "多阶段", "strategy", "architecture", "research")
return len(source) > 80 or memory_hint is not None or any(
marker in source or marker in text for marker in markers
)
async def _run_auto_swarm_builder(self, task: str, skills: list[str], memory_hint: Any) -> dict[str, Any]:
try:
runtime = load_swarms_runtime()
builder = runtime["AutoSwarmBuilder"](
name="nanobot-auto-swarm-builder",
description="Generate a safe swarms router config for nanobot",
max_loops=1,
model_name=self._auto_builder_model_name(),
generate_router_config=True,
execution_type="return-swarm-router-config",
interactive=False,
verbose=False,
)
raw = await asyncio.to_thread(
builder.run,
self._auto_builder_prompt(task, skills, memory_hint),
)
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
return json.loads(raw)
model_dump = getattr(raw, "model_dump", None)
if callable(model_dump):
payload = model_dump()
return payload if isinstance(payload, dict) else {}
except Exception as exc:
logger.warning("AutoSwarmBuilder failed; falling back to deterministic run spec: {}", exc)
return {}
def _auto_builder_model_name(self) -> str:
model_name = str(self.model or "").strip()
if not model_name:
return "gpt-4.1"
if "/" in model_name:
return model_name
return f"openai/{model_name}"
def _auto_builder_prompt(self, task: str, skills: list[str], memory_hint: Any) -> str:
return (
"Build a multi-agent swarm router config for nanobot.\n\n"
f"User task:\n{task}\n\n"
f"Required nanobot skills:\n{skills}\n\n"
f"Procedure memory hint:\n{memory_hint}\n\n"
"Return a valid JSON object that matches the swarm router config schema.\n\n"
"Hard constraints:\n"
"- Every generated role must follow the listed skills.\n"
"- Do not replace, ignore, or reinterpret the listed skills.\n"
"- Do not add external tools, credentials, MCP URLs, or hidden side effects.\n"
"- Prefer existing nanobot registry agents; only describe missing roles."
)
async def _spec_from_auto_config(
self,
task: str,
label: str,
skills: list[str],
raw_config: dict[str, Any],
) -> SwarmsRunSpec:
safe_config = self.policy.validate_auto_config(raw_config)
target_plan = await self.target_resolver.resolve_team_targets(
task=task,
skills=skills,
required_specialists=self._roles_from_auto_config(safe_config),
)
return SwarmsRunSpec(
task=task,
label=label,
skills=list(skills),
swarm_type=str(safe_config.get("swarm_type") or "GroupChat"),
agent_ids=list(target_plan.final_targets),
auto_generated=bool(raw_config),
max_loops=min(int(safe_config.get("max_loops") or 2), self.policy.max_loops),
rearrange_flow=self._rearrange_flow(safe_config, target_plan.final_targets),
rules=str(safe_config.get("rules") or self._default_rules()),
raw_auto_config=safe_config,
metadata={
"target_plan": target_plan.to_dict(),
"auto_builder_returned_config": bool(raw_config),
},
)
def _rearrange_flow(self, config: dict[str, Any], agent_ids: list[str]) -> str | None:
if str(config.get("swarm_type") or "") == "AgentRearrange" and agent_ids:
return " -> ".join(safe_swarms_name(agent_id) for agent_id in agent_ids)
flow = config.get("rearrange_flow") or config.get("flow")
if flow:
return str(flow)
return None
def _roles_from_auto_config(self, config: dict[str, Any]) -> list[str]:
roles: list[str] = []
for item in config.get("agents", []) or []:
if not isinstance(item, dict):
continue
role = str(
item.get("description")
or item.get("system_prompt")
or item.get("agent_name")
or ""
).strip()
if role:
roles.append(role)
return roles or ["general specialist", "synthesis analyst"]
def _simple_required_roles(self, task: str, skills: list[str]) -> list[str]:
if skills:
return [f"{skill} specialist" for skill in skills]
return ["general specialist", "synthesis analyst"]
def _default_rules(self) -> str:
return (
"You are running inside a nanobot agent team. Follow the provided skills, "
"stay within your assigned role, and produce a concise final synthesis."
)

View File

@ -0,0 +1,70 @@
"""Policy guardrails for swarms-generated agent team plans."""
from __future__ import annotations
from typing import Any
class SwarmsPolicy:
"""Clamp AutoSwarmBuilder output before nanobot executes it."""
allowed_swarm_types = {
# Keep this list to swarms that consume the provided nanobot agent adapters.
"GroupChat",
"SequentialWorkflow",
"ConcurrentWorkflow",
"AgentRearrange",
"MixtureOfAgents",
"HierarchicalSwarm",
}
def __init__(self, *, max_agents: int = 4, max_loops: int = 3) -> None:
self.max_agents = max(1, max_agents)
self.max_loops = max(1, max_loops)
def validate_auto_config(self, raw_config: dict[str, Any]) -> dict[str, Any]:
config = self._plain_dict(raw_config)
swarm_type = str(
config.get("swarm_type")
or config.get("type")
or config.get("architecture")
or "GroupChat"
)
if swarm_type not in self.allowed_swarm_types:
swarm_type = "GroupChat"
config["swarm_type"] = swarm_type
agents = list(config.get("agents") or [])[: self.max_agents]
config["agents"] = [self._sanitize_agent_spec(item) for item in agents]
config["max_loops"] = min(max(1, int(config.get("max_loops") or 2)), self.max_loops)
# AutoSwarmBuilder may suggest structure, not grant capabilities.
config.pop("tools", None)
config.pop("mcp_url", None)
config.pop("mcp_urls", None)
config.pop("llm_api_key", None)
config.pop("api_key", None)
return config
def _plain_dict(self, raw_config: Any) -> dict[str, Any]:
if isinstance(raw_config, dict):
return dict(raw_config)
model_dump = getattr(raw_config, "model_dump", None)
if callable(model_dump):
payload = model_dump()
return dict(payload) if isinstance(payload, dict) else {}
dict_method = getattr(raw_config, "dict", None)
if callable(dict_method):
payload = dict_method()
return dict(payload) if isinstance(payload, dict) else {}
return {}
def _sanitize_agent_spec(self, item: Any) -> dict[str, Any]:
spec = self._plain_dict(item)
return {
"agent_name": str(spec.get("agent_name") or spec.get("name") or "specialist"),
"description": str(spec.get("description") or spec.get("agent_description") or ""),
"system_prompt": str(spec.get("system_prompt") or "")[:4000],
"role": str(spec.get("role") or "worker"),
}

View File

@ -0,0 +1,267 @@
"""Resolve and provision team targets before execution.
该模块负责在真正启动 agent-team / swarms 执行前,把“任务需要哪些角色”
转换成一组可执行的 agent id。它优先复用 registry 里已有的 agent当没有合适
agent 覆盖某个角色时,再通过 ProvisioningManager 在本地创建 A2A specialist。
"""
from __future__ import annotations
from pathlib import Path
from loguru import logger
from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry
from nanobot.agent_team.provisioning import ProvisioningManager
from nanobot.agent_team.types import ResolvedTeamPlan
from nanobot.providers.base import LLMProvider
class TargetResolver:
"""把任务级的 specialist 需求解析成最终可执行的 agent id 列表。
解析策略分两层:
1. 先读取当前 registry 里所有可见 agent并过滤掉 router/planner 等
不适合作为群聊工作成员的 agent。
2. 如果调用方明确给出 required_specialists则把 role 和候选 agent 交给
LLM 直接选择最合适的已有 agentLLM 选不出来时才 provision 本地
specialist。没有明确角色时则直接使用过滤后的已有 agent若为空再
兜底创建 general specialist。
"""
def __init__(
self,
*,
workspace: Path,
registry: AgentRegistry,
provider: LLMProvider,
model: str | None = None,
max_parallel_agents: int = 16,
gateway_port: int = 18790,
provisioning: ProvisioningManager | None = None,
) -> None:
# max_parallel_agents 同时限制“最多尝试的角色数”和“最终返回的 agent 数”,
# 避免一次 team run 生成过多并行成员。
self.workspace = workspace
self.registry = registry
self.provider = provider
self.model = model or provider.get_default_model()
self.max_parallel_agents = max(1, max_parallel_agents)
self.provisioning = provisioning or ProvisioningManager(workspace, gateway_port=gateway_port)
async def resolve_team_targets(
self,
*,
task: str,
skills: list[str] | None = None,
required_specialists: list[str] | None = None,
) -> ResolvedTeamPlan:
"""解析一次 team run 的目标 agent。
Args:
task: 用户原始任务,用于 LLM 选 agent 和 specialist provision prompt。
skills: 本次任务要求携带的技能列表,会传给新 provision 的 specialist。
required_specialists: 上游 planner 推导出的角色需求。例如来自
AutoSwarmBuilder config 的 agent description或 skills 的简单映射。
Returns:
ResolvedTeamPlan: 包含已复用 agent、已 provision agent、最终执行目标、
选择理由和审计 metadata。
"""
# 清理空字符串/空白角色,避免后续创建出没有意义的 specialist。
required = [item for item in (required_specialists or []) if str(item).strip()]
# 直接读取 registry 当前所有可见 agent再过滤掉 router、planner、
# local-subagent 这类不适合作为 swarms/group worker 的 agent。
suggestions = [
agent
for agent in self.registry.list_agents(include_local_fallback=False)
if self._is_group_worker_candidate(agent)
]
# selected: 从 registry 复用的已有 agent id。
# covered_roles: 哪些 required role 已经被已有 agent 覆盖,用于 metadata。
# provisioned: 为缺失角色新建/确保存在的本地 specialist id。
# created_provisioned: 本次 run 真正新建出来的 specialist id后续自动清理只看它
# 避免把之前已经存在、只是被刷新/复用的 specialist 误删。
# actions: provision 审计记录,方便上层解释“为什么创建了某个 agent”。
selected: list[str] = []
covered_roles: list[str] = []
provisioned: list[str] = []
created_provisioned: list[str] = []
actions: list[dict[str, str]] = []
if required:
# 调用方给出了明确角色时,不再做本地词法规则匹配,而是直接把
# role + task + 候选 agent 交给 LLM 判断最适合复用哪个已有 agent。
# 这里切片是为了遵守 max_parallel_agents 上限。
for role in required[: self.max_parallel_agents]:
existing = await self._select_existing_for_role_with_llm(
task=task,
role=role,
suggestions=suggestions,
selected=selected,
)
if existing is not None:
selected.append(existing.id)
covered_roles.append(role)
continue
provision_result = await self.provisioning.ensure_local_specialist_with_result(
role=role,
task=task,
skills=skills or [],
)
agent_id = provision_result.agent_id
provisioned.append(agent_id)
if provision_result.created:
created_provisioned.append(agent_id)
actions.append({
"action": "ensure_local_specialist",
"role": role,
"agent_id": agent_id,
"created": str(provision_result.created).lower(),
})
else:
# 没有明确角色需求时,直接使用当前可见的已有 agent最多取并行上限。
selected = [agent.id for agent in suggestions[: self.max_parallel_agents]]
if not selected:
# 当前 registry 没有可用 worker 时,创建一个通用 specialist 作为最低可执行兜底。
provision_result = await self.provisioning.ensure_local_specialist_with_result(
role="general specialist",
task=task,
skills=skills or [],
)
agent_id = provision_result.agent_id
provisioned.append(agent_id)
if provision_result.created:
created_provisioned.append(agent_id)
actions.append({
"action": "ensure_local_specialist",
"role": "general specialist",
"agent_id": agent_id,
"created": str(provision_result.created).lower(),
})
# 合并已有 agent 和新 provision 的 agent
# - dict.fromkeys 保留顺序并去重,避免同一个 agent 被重复加入;
# - 最后再次截断,防止 selected + provisioned 总数超过并行上限。
final_targets = list(dict.fromkeys([*selected, *provisioned]))[: self.max_parallel_agents]
# selection_reason 是给上层/日志展示的粗粒度解释metadata 里会保留更细的明细。
reason = (
"已选择现有 registry agent。"
if selected and not provisioned
else "已选择现有 registry agent并为缺失角色补充了 specialist。"
if selected and provisioned
else "没有匹配到合适的现有 agent已补充本地 A2A specialist。"
if provisioned
else "没有匹配到合适的现有 agent且未补充任何 specialist。"
)
logger.info(
"Resolved agent-team targets selected={} provisioned={} final={}",
selected,
provisioned,
final_targets,
)
# ResolvedTeamPlan 是后续 orchestrator/swarms planner 使用的稳定边界:
# final_targets 用于实际执行selected/provisioned/actions/metadata 用于解释和调试。
return ResolvedTeamPlan(
selected_existing_targets=selected,
provisioned_targets=provisioned,
created_provisioned_targets=created_provisioned,
final_targets=final_targets,
selection_reason=reason,
provision_actions=actions,
metadata={
"required_specialists": required,
"available_agent_count": len(suggestions),
"covered_roles": covered_roles,
"created_provisioned_targets": created_provisioned,
"max_parallel_agents": self.max_parallel_agents,
},
)
@staticmethod
def _is_group_worker_candidate(agent: AgentDescriptor) -> bool:
"""判断一个 registry agent 是否适合作为 team/group worker。
router/planner 类 agent 通常负责调度,不应被当作普通成员加入 GroupChat 或
swarms worker 列表local-subagent 是通用本地代理入口,也避免在这里重复选中。
"""
probe = " ".join([
agent.id,
agent.name,
agent.description,
" ".join(agent.tags),
" ".join(agent.aliases),
]).lower()
if agent.id == "local-subagent":
return False
return not any(marker in probe for marker in ("chat-router", "router", "planner"))
async def _select_existing_for_role_with_llm(
self,
*,
task: str,
role: str,
suggestions: list[AgentDescriptor],
selected: list[str],
) -> AgentDescriptor | None:
"""让 LLM 从已有候选 agent 中为 role 选择最合适的一个。"""
candidates = [agent for agent in suggestions if agent.id not in selected]
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
lines = []
for agent in candidates:
tags = ", ".join(agent.tags) if agent.tags else "none"
aliases = ", ".join(agent.aliases) if agent.aliases else "none"
lines.append(
f"- id: {agent.id}\n"
f" name: {agent.name}\n"
f" description: {agent.description}\n"
f" tags: {tags}\n"
f" aliases: {aliases}"
)
try:
response = await self.provider.chat(
messages=[
{
"role": "system",
"content": (
"You select one existing agent for a required team role.\n"
"Return exactly one agent id from the candidate list, or NONE.\n"
"Do not explain your reasoning."
),
},
{
"role": "user",
"content": (
f"Task:\n{task}\n\n"
f"Required role:\n{role}\n\n"
"Candidates:\n"
f"{chr(10).join(lines)}\n\n"
"Return exactly one candidate id, or NONE if none of them clearly fits."
),
},
],
model=self.model,
temperature=0,
max_tokens=32,
)
except Exception as exc:
logger.warning("LLM role selection failed for role '{}': {}", role, exc)
return None
raw = str(response.content or "").strip()
choice = raw.splitlines()[0].strip().strip("`'\"") if raw else ""
candidate_map = {agent.id: agent for agent in candidates}
if choice in candidate_map:
return candidate_map[choice]
if choice.upper() not in {"", "NONE"}:
logger.info("LLM role selection returned unknown agent id '{}' for role '{}'", choice, role)
return None

View File

@ -0,0 +1,546 @@
"""Agent Team swarms 适配层的共享类型定义。"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from nanobot.agent.run_result import AgentRunResult
def now_iso() -> str:
"""返回统一格式的 UTC 时间戳字符串。
Demo 输出:
`2026-03-31T12:00:00.000000+00:00`
"""
# 统一使用 UTC避免跨机器或跨时区比较 run/procedure 时间时出现歧义。
return datetime.now(timezone.utc).isoformat()
def new_record_id(prefix: str) -> str:
"""为 memory 记录生成短 ID。
Demo 输出:
`procedure-3fa2c7b1`
"""
# 这里保留可读前缀,方便磁盘文件、日志和测试断言定位数据来源。
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def agent_result_to_dict(result: AgentRunResult) -> dict[str, Any]:
"""把 `AgentRunResult` 转成可 JSON 序列化的字典。
Demo 输出:
`{"agent_id": "writer", "agent_name": "Writer", "status": "ok", "summary": "...", "raw": {}}`
"""
# `raw` 允许为空,这里统一转成字典或 None避免后续序列化分支散落各处。
return {
"agent_id": result.agent_id,
"agent_name": result.agent_name,
"status": result.status,
"summary": result.summary,
"raw": result.raw,
}
def agent_result_from_dict(payload: dict[str, Any]) -> AgentRunResult:
"""从字典重建 `AgentRunResult`。
Demo 输出:
`AgentRunResult(agent_id="writer", agent_name="Writer", status="ok", summary="...", raw=None)`
"""
# 所有字段都做最小兜底,防止历史磁盘记录缺字段时直接炸掉整个读取流程。
return AgentRunResult(
agent_id=str(payload.get("agent_id") or "unknown-agent"),
agent_name=str(payload.get("agent_name") or payload.get("agent_id") or "Unknown Agent"),
status=str(payload.get("status") or "error"),
summary=str(payload.get("summary") or ""),
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None,
)
class ExecutionMode(str, Enum):
"""编排器支持的执行模式。"""
SWARMS = "swarms"
def parse_execution_mode(value: Any, default: ExecutionMode = ExecutionMode.SWARMS) -> ExecutionMode:
"""把持久化里的 mode 字符串解析成 ExecutionMode。"""
raw = str(value or default.value)
try:
return ExecutionMode(raw)
except ValueError:
return default
@dataclass(slots=True)
class ResolvedTeamPlan:
"""最终执行前解析出的成员计划。"""
selected_existing_targets: list[str] = field(default_factory=list)
provisioned_targets: list[str] = field(default_factory=list)
created_provisioned_targets: list[str] = field(default_factory=list)
final_targets: list[str] = field(default_factory=list)
selection_reason: str = ""
provision_actions: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"selected_existing_targets": list(self.selected_existing_targets),
"provisioned_targets": list(self.provisioned_targets),
"created_provisioned_targets": list(self.created_provisioned_targets),
"final_targets": list(self.final_targets),
"selection_reason": self.selection_reason,
"provision_actions": [dict(item) for item in self.provision_actions],
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "ResolvedTeamPlan":
return cls(
selected_existing_targets=[
str(item)
for item in payload.get("selected_existing_targets", [])
if str(item).strip()
],
provisioned_targets=[
str(item)
for item in payload.get("provisioned_targets", [])
if str(item).strip()
],
created_provisioned_targets=[
str(item)
for item in payload.get("created_provisioned_targets", [])
if str(item).strip()
],
final_targets=[
str(item)
for item in payload.get("final_targets", [])
if str(item).strip()
],
selection_reason=str(payload.get("selection_reason") or ""),
provision_actions=[
dict(item)
for item in payload.get("provision_actions", [])
if isinstance(item, dict)
],
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
@dataclass(slots=True)
class SwarmsRunSpec:
"""nanobot 交给 swarms runtime 的最小运行规格。"""
task: str
label: str
skills: list[str]
swarm_type: str
agent_ids: list[str]
auto_generated: bool = False
max_loops: int = 2
rearrange_flow: str | None = None
rules: str | None = None
raw_auto_config: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"task": self.task,
"label": self.label,
"skills": list(self.skills),
"swarm_type": self.swarm_type,
"agent_ids": list(self.agent_ids),
"auto_generated": self.auto_generated,
"max_loops": self.max_loops,
"rearrange_flow": self.rearrange_flow,
"rules": self.rules,
"raw_auto_config": dict(self.raw_auto_config),
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunSpec":
return cls(
task=str(payload.get("task") or ""),
label=str(payload.get("label") or ""),
skills=[str(item) for item in payload.get("skills", []) if str(item).strip()],
swarm_type=str(payload.get("swarm_type") or "GroupChat"),
agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()],
auto_generated=bool(payload.get("auto_generated", False)),
max_loops=max(1, int(payload.get("max_loops") or 2)),
rearrange_flow=str(payload["rearrange_flow"]) if payload.get("rearrange_flow") else None,
rules=str(payload["rules"]) if payload.get("rules") else None,
raw_auto_config=payload.get("raw_auto_config") if isinstance(payload.get("raw_auto_config"), dict) else {},
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
@dataclass(slots=True)
class SwarmsRunResult:
"""swarms runtime 的原始输出归一化前结果。"""
success: bool
summary: str
raw_output: Any
error: str | None = None
member_results: list[AgentRunResult] = field(default_factory=list)
transcript: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"success": self.success,
"summary": self.summary,
"raw_output": self.raw_output,
"error": self.error,
"member_results": [agent_result_to_dict(item) for item in self.member_results],
"transcript": [dict(item) for item in self.transcript],
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunResult":
return cls(
success=bool(payload.get("success", False)),
summary=str(payload.get("summary") or ""),
raw_output=payload.get("raw_output"),
error=str(payload["error"]) if payload.get("error") else None,
member_results=[
agent_result_from_dict(item)
for item in payload.get("member_results", [])
if isinstance(item, dict)
],
transcript=[
dict(item)
for item in payload.get("transcript", [])
if isinstance(item, dict)
],
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
@dataclass(slots=True)
class ProcedureRecord:
"""一条可复用的 procedure 记录。
Demo 输出:
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', agent_ids=['writer-agent'], strategy='single', confidence=0.65, ...)`
"""
# 稳定主键会被 `RunMemory` 和公告信息引用。
id: str = field(default_factory=lambda: new_record_id("procedure"))
# 原始任务模板用于向后续执行注入“之前学到的做法”。
task_template: str = ""
# 一句话总结这个 procedure 适用的场景和执行方式。
summary: str = ""
# swarms bridge 会按这里列出的 agent 顺序/组合执行。
agent_ids: list[str] = field(default_factory=list)
# 第一版只实现 `single | parallel` 两种策略。
strategy: str = "parallel"
# 用简单关键词做粗粒度匹配,避免引入重型向量索引。
task_keywords: list[str] = field(default_factory=list)
# 置信度用于后续复用和人工排查。
confidence: float = 0.5
# 成功/失败计数用来估算 failure rate。
success_count: int = 0
failure_count: int = 0
# 便于追踪该 procedure 从哪次探索 run 学来。
source_run_id: str | None = None
# 标准时间字段全部保留,方便 UI 或后续排序扩展。
created_at: str = field(default_factory=now_iso)
updated_at: str = field(default_factory=now_iso)
last_used_at: str | None = None
# 额外扩展字段集中收口到 metadata避免频繁改 schema。
metadata: dict[str, Any] = field(default_factory=dict)
def failure_rate(self) -> float:
"""计算该 procedure 的累计失败率。
Demo 输出:
`0.25`
"""
# 没有历史执行时直接返回 0避免“新 procedure 天生失败率 100%”的误判。
total = self.success_count + self.failure_count
if total <= 0:
return 0.0
return self.failure_count / total
def to_dict(self) -> dict[str, Any]:
"""把 procedure 记录转成字典。
Demo 输出:
`{"id": "procedure-a1b2c3d4", "strategy": "parallel", "agent_ids": ["agent-a", "agent-b"], ...}`
"""
return {
"id": self.id,
"task_template": self.task_template,
"summary": self.summary,
"agent_ids": list(self.agent_ids),
"strategy": self.strategy,
"task_keywords": list(self.task_keywords),
"confidence": self.confidence,
"success_count": self.success_count,
"failure_count": self.failure_count,
"source_run_id": self.source_run_id,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_used_at": self.last_used_at,
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "ProcedureRecord":
"""从字典重建 procedure 记录。
Demo 输出:
`ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)`
"""
return cls(
id=str(payload.get("id") or new_record_id("procedure")),
task_template=str(payload.get("task_template") or ""),
summary=str(payload.get("summary") or ""),
agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()],
strategy=str(payload.get("strategy") or "parallel"),
task_keywords=[
str(item)
for item in payload.get("task_keywords", [])
if str(item).strip()
],
confidence=float(payload.get("confidence") or 0.5),
success_count=int(payload.get("success_count") or 0),
failure_count=int(payload.get("failure_count") or 0),
source_run_id=str(payload["source_run_id"]) if payload.get("source_run_id") else None,
created_at=str(payload.get("created_at") or now_iso()),
updated_at=str(payload.get("updated_at") or now_iso()),
last_used_at=str(payload["last_used_at"]) if payload.get("last_used_at") else None,
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
@dataclass(slots=True)
class RunRecord:
"""一次 agent team 运行的持久化记录。
Demo 输出:
`RunRecord(id='run-1a2b3c4d', task='生成周报', mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, ...)`
"""
# run 记录也使用短 ID便于文件和日志双向检索。
id: str = field(default_factory=lambda: new_record_id("run"))
# 原始任务文本是最重要的回溯信息,必须完整保留。
task: str = ""
# 执行模式会用于后续做简单统计和问题排查。
mode: ExecutionMode = ExecutionMode.SWARMS
# 归一化成功标记。
success: bool = False
# 最终摘要可直接展示在运维面板或调试脚本里。
summary: str = ""
# 失败时保留错误信息;成功时为 None。
error: str | None = None
# 命中的 procedure 主键,没有命中则为空。
procedure_id: str | None = None
# 记录创建时间。
created_at: str = field(default_factory=now_iso)
# metadata 会保存 attempts、raw 等调试信息。
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""把 run 记录转成字典。
Demo 输出:
`{"id": "run-1a2b3c4d", "mode": "swarms", "success": true, ...}`
"""
return {
"id": self.id,
"task": self.task,
"mode": self.mode.value,
"success": self.success,
"summary": self.summary,
"error": self.error,
"procedure_id": self.procedure_id,
"created_at": self.created_at,
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "RunRecord":
"""从字典重建 run 记录。
Demo 输出:
`RunRecord(id='run-1a2b3c4d', task='生成周报', mode=<ExecutionMode.SWARMS: 'swarms'>, ...)`
"""
return cls(
id=str(payload.get("id") or new_record_id("run")),
task=str(payload.get("task") or ""),
mode=parse_execution_mode(payload.get("mode")),
success=bool(payload.get("success", False)),
summary=str(payload.get("summary") or ""),
error=str(payload["error"]) if payload.get("error") else None,
procedure_id=str(payload["procedure_id"]) if payload.get("procedure_id") else None,
created_at=str(payload.get("created_at") or now_iso()),
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
@dataclass(slots=True)
class BridgeAttempt:
"""单次 bridge 执行尝试的归一化结果。
Demo 输出:
`BridgeAttempt(mode=<ExecutionMode.SWARMS: 'swarms'>, success=False, summary='执行失败', error='timeout', targets=['writer-agent'])`
"""
# 记录尝试来自哪个 bridge便于 swarms 链路审计。
mode: ExecutionMode
# 是否成功决定最终团队结果状态。
success: bool
# 本次尝试的聚合摘要。
summary: str
# 若失败,则记录错误原因。
error: str | None = None
# 保留成员级结果,供公告和测试直接读取。
member_results: list[AgentRunResult] = field(default_factory=list)
# 记录本次尝试的目标 agent。
targets: list[str] = field(default_factory=list)
# 透传底层调试字段。
raw: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""把单次尝试转成字典。
Demo 输出:
`{"mode": "swarms", "success": false, "targets": ["writer-agent"], ...}`
"""
return {
"mode": self.mode.value,
"success": self.success,
"summary": self.summary,
"error": self.error,
"member_results": [agent_result_to_dict(item) for item in self.member_results],
"targets": list(self.targets),
"raw": dict(self.raw),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "BridgeAttempt":
"""从字典重建单次尝试。
Demo 输出:
`BridgeAttempt(mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, summary='swarms 完成', ...)`
"""
return cls(
mode=parse_execution_mode(payload.get("mode")),
success=bool(payload.get("success", False)),
summary=str(payload.get("summary") or ""),
error=str(payload["error"]) if payload.get("error") else None,
member_results=[
agent_result_from_dict(item)
for item in payload.get("member_results", [])
if isinstance(item, dict)
],
targets=[str(item) for item in payload.get("targets", []) if str(item).strip()],
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {},
)
@dataclass(slots=True)
class BridgeResult:
"""统一封装 `SwarmsBridge` 的最终输出。
Demo 输出:
`BridgeResult(mode=<ExecutionMode.SWARMS: 'swarms'>, success=True, summary='swarms 已完成', ...)`
"""
# 最终采用的执行模式。
mode: ExecutionMode
# 编排结果是否成功。
success: bool
# 最终可展示摘要。
summary: str
# 失败时的归一化错误说明。
error: str | None = None
# 当前结果对应的成员结果,一般取最终一次 attempt。
member_results: list[AgentRunResult] = field(default_factory=list)
# 探索阶段提炼出的候选 procedure。
candidate_procedure: ProcedureRecord | None = None
# 命中的历史 procedure便于公告和 run 记录追踪。
matched_procedure: ProcedureRecord | None = None
# 支持记录多次尝试,便于后续扩展到 swarms 内部多阶段路由。
attempts: list[BridgeAttempt] = field(default_factory=list)
# 原始调试字段统一放在这里。
raw: dict[str, Any] = field(default_factory=dict)
def last_member_results(self) -> list[AgentRunResult]:
"""返回最后一次有成员结果的 attempt。
Demo 输出:
`[AgentRunResult(agent_id='writer-agent', agent_name='Writer Agent', status='ok', summary='...', raw=None)]`
"""
# 优先使用显式写入的最终成员结果,避免每次都从 attempts 倒推。
if self.member_results:
return list(self.member_results)
# 若最终结果没显式写入,则从最后一个有成员结果的 attempt 回退。
for attempt in reversed(self.attempts):
if attempt.member_results:
return list(attempt.member_results)
return []
def to_dict(self) -> dict[str, Any]:
"""把 bridge 结果转成字典。
Demo 输出:
`{"mode": "exploration", "success": true, "attempts": [...], "candidate_procedure": {...}}`
"""
return {
"mode": self.mode.value,
"success": self.success,
"summary": self.summary,
"error": self.error,
"member_results": [agent_result_to_dict(item) for item in self.member_results],
"candidate_procedure": self.candidate_procedure.to_dict() if self.candidate_procedure else None,
"matched_procedure": self.matched_procedure.to_dict() if self.matched_procedure else None,
"attempts": [attempt.to_dict() for attempt in self.attempts],
"raw": dict(self.raw),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "BridgeResult":
"""从字典重建 bridge 结果。
Demo 输出:
`BridgeResult(mode=<ExecutionMode.SWARMS: 'swarms'>, success=False, summary='执行失败', ...)`
"""
return cls(
mode=parse_execution_mode(payload.get("mode")),
success=bool(payload.get("success", False)),
summary=str(payload.get("summary") or ""),
error=str(payload["error"]) if payload.get("error") else None,
member_results=[
agent_result_from_dict(item)
for item in payload.get("member_results", [])
if isinstance(item, dict)
],
candidate_procedure=(
ProcedureRecord.from_dict(payload["candidate_procedure"])
if isinstance(payload.get("candidate_procedure"), dict)
else None
),
matched_procedure=(
ProcedureRecord.from_dict(payload["matched_procedure"])
if isinstance(payload.get("matched_procedure"), dict)
else None
),
attempts=[
BridgeAttempt.from_dict(item)
for item in payload.get("attempts", [])
if isinstance(item, dict)
],
raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {},
)

View File

@ -287,7 +287,10 @@ def _make_provider(config: Config):
# OpenAI Codex (OAuth) # OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"): if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model) return OpenAICodexProvider(
default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
if provider_name == "custom": if provider_name == "custom":
@ -295,6 +298,7 @@ def _make_provider(config: Config):
api_key=p.api_key if p else "no-key", api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1", api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model, default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
) )
# LiteLLM 通道:绝大多数 provider 走这里。 # LiteLLM 通道:绝大多数 provider 走这里。
@ -311,6 +315,7 @@ def _make_provider(config: Config):
default_model=model, default_model=model,
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
provider_name=provider_name, provider_name=provider_name,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
) )
@ -387,6 +392,7 @@ def gateway(
channels_config=config.channels, channels_config=config.channels,
authz_config=config.authz, authz_config=config.authz,
backend_identity=config.backend_identity, backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
) )
# 把 cron 执行回调绑定到 agent定时触发时会走一次完整 agent 处理流程。 # 把 cron 执行回调绑定到 agent定时触发时会走一次完整 agent 处理流程。
@ -523,6 +529,7 @@ def web(
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
config = load_config() config = load_config()
config.gateway.port = port
_create_workspace_templates(config.workspace_path) _create_workspace_templates(config.workspace_path)
console.print(f"{__brand__}: starting web backend on {host}:{port}...") console.print(f"{__brand__}: starting web backend on {host}:{port}...")
@ -596,6 +603,7 @@ def agent(
channels_config=config.channels, channels_config=config.channels,
authz_config=config.authz, authz_config=config.authz,
backend_identity=config.backend_identity, backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
) )
# `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。 # `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。
@ -1217,6 +1225,7 @@ def cron_run(
channels_config=config.channels, channels_config=config.channels,
authz_config=config.authz, authz_config=config.authz,
backend_identity=config.backend_identity, backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
) )
store_path = get_cron_store_path(config.workspace_path) store_path = get_cron_store_path(config.workspace_path)

View File

@ -288,6 +288,7 @@ class ProviderConfig(Base):
api_key: str = "" api_key: str = ""
api_base: str | None = None api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
request_timeout_seconds: int = 600
class ProvidersConfig(Base): class ProvidersConfig(Base):
@ -368,7 +369,7 @@ class A2AConfig(Base):
# 总开关,预留给未来需要完全禁用远程委派的场景。 # 总开关,预留给未来需要完全禁用远程委派的场景。
enabled: bool = True enabled: bool = True
# 单次远程任务的最长等待时间(秒)。 # 单次远程任务的最长等待时间(秒)。
timeout_seconds: int = 30 timeout_seconds: int = 600
# 非流式任务轮询间隔(秒)。 # 非流式任务轮询间隔(秒)。
poll_interval_seconds: int = 2 poll_interval_seconds: int = 2
# agent card 本地缓存 TTL避免每次委派都重新拉远端元数据。 # agent card 本地缓存 TTL避免每次委派都重新拉远端元数据。

View File

@ -36,9 +36,19 @@ class LLMProvider(ABC):
while maintaining a consistent interface. while maintaining a consistent interface.
""" """
def __init__(self, api_key: str | None = None, api_base: str | None = None): def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
request_timeout_seconds: float | None = None,
):
self.api_key = api_key self.api_key = api_key
self.api_base = api_base self.api_base = api_base
self.request_timeout_seconds = (
max(1.0, float(request_timeout_seconds))
if request_timeout_seconds is not None
else None
)
@staticmethod @staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:

View File

@ -12,10 +12,20 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
class CustomProvider(LLMProvider): class CustomProvider(LLMProvider):
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): def __init__(
super().__init__(api_key, api_base) self,
api_key: str = "no-key",
api_base: str = "http://localhost:8000/v1",
default_model: str = "default",
request_timeout_seconds: float | None = None,
):
super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds)
self.default_model = default_model self.default_model = default_model
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) self._client = AsyncOpenAI(
api_key=api_key,
base_url=api_base,
timeout=self.request_timeout_seconds,
)
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
@ -49,4 +59,3 @@ class CustomProvider(LLMProvider):
def get_default_model(self) -> str: def get_default_model(self) -> str:
return self.default_model return self.default_model

View File

@ -44,8 +44,9 @@ class LiteLLMProvider(LLMProvider):
default_model: str = "anthropic/claude-opus-4-5", default_model: str = "anthropic/claude-opus-4-5",
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
provider_name: str | None = None, provider_name: str | None = None,
request_timeout_seconds: float | None = None,
): ):
super().__init__(api_key, api_base) super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds)
self.default_model = default_model self.default_model = default_model
self.extra_headers = extra_headers or {} self.extra_headers = extra_headers or {}
@ -230,6 +231,9 @@ class LiteLLMProvider(LLMProvider):
# Pass extra headers (e.g. APP-Code for AiHubMix) # Pass extra headers (e.g. APP-Code for AiHubMix)
if self.extra_headers: if self.extra_headers:
kwargs["extra_headers"] = self.extra_headers kwargs["extra_headers"] = self.extra_headers
if self.request_timeout_seconds is not None:
kwargs["timeout"] = self.request_timeout_seconds
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
@ -246,6 +250,7 @@ class LiteLLMProvider(LLMProvider):
"has_api_key": bool(self.api_key), "has_api_key": bool(self.api_key),
"temperature": kwargs.get("temperature"), "temperature": kwargs.get("temperature"),
"max_tokens": kwargs.get("max_tokens"), "max_tokens": kwargs.get("max_tokens"),
"timeout": kwargs.get("timeout"),
"tool_choice": kwargs.get("tool_choice"), "tool_choice": kwargs.get("tool_choice"),
"message_count": len(sanitized_messages), "message_count": len(sanitized_messages),
"messages": summarize_messages(sanitized_messages), "messages": summarize_messages(sanitized_messages),

View File

@ -20,8 +20,12 @@ DEFAULT_ORIGINATOR = "nanobot"
class OpenAICodexProvider(LLMProvider): class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API.""" """Use Codex OAuth to call the Responses API."""
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"): def __init__(
super().__init__(api_key=None, api_base=None) self,
default_model: str = "openai-codex/gpt-5.1-codex",
request_timeout_seconds: float | None = None,
):
super().__init__(api_key=None, api_base=None, request_timeout_seconds=request_timeout_seconds)
self.default_model = default_model self.default_model = default_model
async def chat( async def chat(
@ -58,12 +62,24 @@ class OpenAICodexProvider(LLMProvider):
try: try:
try: try:
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True) content, tool_calls, finish_reason = await _request_codex(
url,
headers,
body,
verify=True,
timeout_seconds=self.request_timeout_seconds or 600.0,
)
except Exception as e: except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e): if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise raise
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False") logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False) content, tool_calls, finish_reason = await _request_codex(
url,
headers,
body,
verify=False,
timeout_seconds=self.request_timeout_seconds or 600.0,
)
return LLMResponse( return LLMResponse(
content=content, content=content,
tool_calls=tool_calls, tool_calls=tool_calls,
@ -102,8 +118,9 @@ async def _request_codex(
headers: dict[str, str], headers: dict[str, str],
body: dict[str, Any], body: dict[str, Any],
verify: bool, verify: bool,
timeout_seconds: float,
) -> tuple[str, list[ToolCallRequest], str]: ) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client: async with httpx.AsyncClient(timeout=timeout_seconds, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response: async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200: if response.status_code != 200:
text = await response.aread() text = await response.aread()

View File

@ -42,7 +42,7 @@ from nanobot.cron.runtime import run_cron_job
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
from nanobot.providers.registry import PROVIDERS from nanobot.providers.registry import PROVIDERS
from nanobot.session.manager import SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import get_cron_store_path, parse_session_key from nanobot.utils.helpers import get_cron_store_path, parse_session_key
if TYPE_CHECKING: if TYPE_CHECKING:
@ -281,23 +281,6 @@ def _slugify_agent_id(*values: Any) -> str:
return "a2a-agent" return "a2a-agent"
def _card_supports_group(card: dict[str, Any]) -> bool:
if "support_group" in card:
return bool(card.get("support_group"))
capabilities = card.get("capabilities")
if not isinstance(capabilities, dict):
return True
group = capabilities.get("group")
if isinstance(group, dict):
for key in ("enabled", "supported"):
if key in group:
return bool(group.get(key))
return True
if group is None:
return True
return bool(group)
async def _discover_agent_payload( async def _discover_agent_payload(
req: AddAgentRequest, req: AddAgentRequest,
config: Config, config: Config,
@ -377,7 +360,6 @@ async def _discover_agent_payload(
"tags": _dedupe_texts(req.tags, card.get("tags")), "tags": _dedupe_texts(req.tags, card.get("tags")),
"aliases": _dedupe_texts(req.aliases, card.get("aliases")), "aliases": _dedupe_texts(req.aliases, card.get("aliases")),
"capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {}, "capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {},
"support_group": _card_supports_group(card),
"support_streaming": client._supports_streaming(card), "support_streaming": client._supports_streaming(card),
"metadata": dict(req.metadata or {}), "metadata": dict(req.metadata or {}),
} }
@ -652,6 +634,7 @@ def create_app(
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
authz_config=config.authz, authz_config=config.authz,
backend_identity=config.backend_identity, backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
) )
async def _handle_direct_delegation_announcement( async def _handle_direct_delegation_announcement(
@ -767,13 +750,17 @@ def _make_provider(config: Config):
p = config.get_provider(model) p = config.get_provider(model)
if provider_name == "openai_codex" or model.startswith("openai-codex/"): if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model) return OpenAICodexProvider(
default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
)
if provider_name == "custom": if provider_name == "custom":
return CustomProvider( return CustomProvider(
api_key=p.api_key if p else "no-key", api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1", api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model, default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
) )
if not (p and p.api_key) and not model.startswith("bedrock/"): if not (p and p.api_key) and not model.startswith("bedrock/"):
@ -785,6 +772,7 @@ def _make_provider(config: Config):
default_model=model, default_model=model,
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
provider_name=provider_name, provider_name=provider_name,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
) )
@ -1174,6 +1162,7 @@ def _register_routes(app: FastAPI) -> None:
allow_local_delegation=allow_local, allow_local_delegation=allow_local,
allow_plugin_delegation=allow_local, allow_plugin_delegation=allow_local,
include_plugin_agents=allow_local, include_plugin_agents=allow_local,
gateway_port=config.gateway.port,
) )
try: try:
return await loop.process_direct( return await loop.process_direct(

View File

@ -44,6 +44,21 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0", "json-repair>=0.57.0,<1.0.0",
"fastapi>=0.115.0,<1.0.0", "fastapi>=0.115.0,<1.0.0",
"uvicorn[standard]>=0.34.0,<1.0.0", "uvicorn[standard]>=0.34.0,<1.0.0",
"psutil>=7.2.2",
"python-dotenv>=1.2.1",
"pyyaml>=6.0.3",
"toml>=0.10.2",
"pypdf==5.1.0",
"ratelimit>=2.2.1",
"tenacity>=9.1.4",
"networkx>=3.6.1",
"aiofiles>=24.1.0",
"requests>=2.32.5",
"aiohttp>=3.13.3",
"numpy>=2.4.4",
"schedule>=1.2.2",
"setuptools>=82.0.1",
"chardet<6",
] ]
[project.optional-dependencies] [project.optional-dependencies]

Submodule app-instance/backend/third_party/swarms added at fe1609f9d5

View File

@ -309,11 +309,11 @@ wheels = [
[[package]] [[package]]
name = "chardet" name = "chardet"
version = "6.0.0.post1" version = "5.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798, upload-time = "2026-02-22T15:09:17.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245, upload-time = "2026-02-22T15:09:15.876Z" }, { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" },
] ]
[[package]] [[package]]
@ -1536,6 +1536,9 @@ name = "nanobot-ai"
version = "0.1.4.post1" version = "0.1.4.post1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
{ name = "chardet" },
{ name = "croniter" }, { name = "croniter" },
{ name = "dingtalk-stream" }, { name = "dingtalk-stream" },
{ name = "fastapi" }, { name = "fastapi" },
@ -1546,19 +1549,31 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "mcp" }, { name = "mcp" },
{ name = "msgpack" }, { name = "msgpack" },
{ name = "networkx" },
{ name = "numpy" },
{ name = "oauth-cli-kit" }, { name = "oauth-cli-kit" },
{ name = "prompt-toolkit" }, { name = "prompt-toolkit" },
{ name = "psutil" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-dotenv" },
{ name = "python-socketio" }, { name = "python-socketio" },
{ name = "python-socks" }, { name = "python-socks" },
{ name = "python-telegram-bot", extra = ["socks"] }, { name = "python-telegram-bot", extra = ["socks"] },
{ name = "pyyaml" },
{ name = "qq-botpy" }, { name = "qq-botpy" },
{ name = "ratelimit" },
{ name = "readability-lxml" }, { name = "readability-lxml" },
{ name = "requests" },
{ name = "rich" }, { name = "rich" },
{ name = "schedule" },
{ name = "setuptools" },
{ name = "slack-sdk" }, { name = "slack-sdk" },
{ name = "slackify-markdown" }, { name = "slackify-markdown" },
{ name = "socksio" }, { name = "socksio" },
{ name = "tenacity" },
{ name = "toml" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "websocket-client" }, { name = "websocket-client" },
@ -1582,6 +1597,9 @@ matrix = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "chardet", specifier = "<6" },
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" }, { name = "croniter", specifier = ">=6.0.0,<7.0.0" },
{ name = "dingtalk-stream", specifier = ">=0.24.0,<1.0.0" }, { name = "dingtalk-stream", specifier = ">=0.24.0,<1.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
@ -1596,24 +1614,36 @@ requires-dist = [
{ name = "mistune", marker = "extra == 'dev'", specifier = ">=3.0.0,<4.0.0" }, { name = "mistune", marker = "extra == 'dev'", specifier = ">=3.0.0,<4.0.0" },
{ name = "mistune", marker = "extra == 'matrix'", specifier = ">=3.0.0,<4.0.0" }, { name = "mistune", marker = "extra == 'matrix'", specifier = ">=3.0.0,<4.0.0" },
{ name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, { name = "msgpack", specifier = ">=1.1.0,<2.0.0" },
{ name = "networkx", specifier = ">=3.6.1" },
{ name = "nh3", marker = "extra == 'dev'", specifier = ">=0.2.17,<1.0.0" }, { name = "nh3", marker = "extra == 'dev'", specifier = ">=0.2.17,<1.0.0" },
{ name = "nh3", marker = "extra == 'matrix'", specifier = ">=0.2.17,<1.0.0" }, { name = "nh3", marker = "extra == 'matrix'", specifier = ">=0.2.17,<1.0.0" },
{ name = "numpy", specifier = ">=2.4.4" },
{ name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" }, { name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" },
{ name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" }, { name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" },
{ name = "psutil", specifier = ">=7.2.2" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
{ name = "pypdf", specifier = "==5.1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-socketio", specifier = ">=5.16.0,<6.0.0" }, { name = "python-socketio", specifier = ">=5.16.0,<6.0.0" },
{ name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" }, { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" },
{ name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.0,<23.0" }, { name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.0,<23.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" }, { name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" },
{ name = "ratelimit", specifier = ">=2.2.1" },
{ name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" }, { name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "rich", specifier = ">=14.0.0,<15.0.0" }, { name = "rich", specifier = ">=14.0.0,<15.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "schedule", specifier = ">=1.2.2" },
{ name = "setuptools", specifier = ">=82.0.1" },
{ name = "slack-sdk", specifier = ">=3.39.0,<4.0.0" }, { name = "slack-sdk", specifier = ">=3.39.0,<4.0.0" },
{ name = "slackify-markdown", specifier = ">=0.2.0,<1.0.0" }, { name = "slackify-markdown", specifier = ">=0.2.0,<1.0.0" },
{ name = "socksio", specifier = ">=1.0.0,<2.0.0" }, { name = "socksio", specifier = ">=1.0.0,<2.0.0" },
{ name = "tenacity", specifier = ">=9.1.4" },
{ name = "toml", specifier = ">=0.10.2" },
{ name = "typer", specifier = ">=0.20.0,<1.0.0" }, { name = "typer", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
{ name = "websocket-client", specifier = ">=1.9.0,<2.0.0" }, { name = "websocket-client", specifier = ">=1.9.0,<2.0.0" },
@ -1621,6 +1651,15 @@ requires-dist = [
] ]
provides-extras = ["matrix", "dev"] provides-extras = ["matrix", "dev"]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]] [[package]]
name = "nh3" name = "nh3"
version = "0.3.3" version = "0.3.3"
@ -1655,6 +1694,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" }, { url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" },
] ]
[[package]]
name = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
]
[[package]] [[package]]
name = "oauth-cli-kit" name = "oauth-cli-kit"
version = "0.1.3" version = "0.1.3"
@ -1834,6 +1952,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
] ]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@ -2022,6 +2168,15 @@ crypto = [
{ name = "cryptography" }, { name = "cryptography" },
] ]
[[package]]
name = "pypdf"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/9a/72d74f05f64895ebf1c7f6646cf7fe6dd124398c5c49240093f92d6f0fdd/pypdf-5.1.0.tar.gz", hash = "sha256:425a129abb1614183fd1aca6982f650b47f8026867c0ce7c4b9f281c443d2740", size = 5011381, upload-time = "2024-10-27T19:46:47.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/fc/6f52588ac1cb4400a7804ef88d0d4e00cfe57a7ac6793ec3b00de5a8758b/pypdf-5.1.0-py3-none-any.whl", hash = "sha256:3bd4f503f4ebc58bae40d81e81a9176c400cbbac2ba2d877367595fb524dfdfc", size = 297976, upload-time = "2024-10-27T19:46:44.439Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@ -2247,6 +2402,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" }, { url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" },
] ]
[[package]]
name = "ratelimit"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" }
[[package]] [[package]]
name = "readability-lxml" name = "readability-lxml"
version = "0.8.4.1" version = "0.8.4.1"
@ -2552,6 +2713,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
] ]
[[package]]
name = "schedule"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
]
[[package]]
name = "setuptools"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@ -2647,6 +2826,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
] ]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]] [[package]]
name = "tiktoken" name = "tiktoken"
version = "0.12.0" version = "0.12.0"
@ -2727,6 +2915,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
] ]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.3" version = "4.67.3"

View File

@ -21,6 +21,40 @@ require_file() {
fi fi
} }
render_swarms_env_file() {
local config_path="$1"
local target_path="$2"
CONFIG_PATH="$config_path" TARGET_PATH="$target_path" python3 - <<'PY'
import json
import os
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
target_path = Path(os.environ["TARGET_PATH"])
data = json.loads(config_path.read_text(encoding="utf-8"))
model = str(data.get("agents", {}).get("defaults", {}).get("model") or "").strip()
if model and "/" not in model:
model = f"openai/{model}"
provider_cfg = data.get("providers", {}).get("openai", {}) or {}
api_key = str(provider_cfg.get("apiKey") or "").strip()
api_base = str(provider_cfg.get("apiBase") or "").strip()
lines = [
'# Generated from /root/.nanobot/config.json for vendored swarms runtime.',
'WORKSPACE_DIR="/root/.nanobot/workspace"',
'SWARMS_VERBOSE_GLOBAL="False"',
'SWARMS_TELEMETRY_ON="false"',
f'SWARMS_DEFAULT_MODEL="{model}"',
f'OPENAI_API_KEY="{api_key}"',
f'OPENAI_API_BASE="{api_base}"',
f'OPENAI_BASE_URL="{api_base}"',
]
target_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
}
cleanup() { cleanup() {
local status=$? local status=$?
@ -51,6 +85,14 @@ fi
require_file "$NANOBOT_HOME/config.json" "Missing Boardware Genius config" require_file "$NANOBOT_HOME/config.json" "Missing Boardware Genius config"
require_file "$NANOBOT_AUTH_FILE" "Missing web auth users file" require_file "$NANOBOT_AUTH_FILE" "Missing web auth users file"
SWARMS_ENV_FILE="/opt/app/backend/third_party/swarms/.env"
render_swarms_env_file "$NANOBOT_HOME/config.json" "$SWARMS_ENV_FILE"
if [[ -f "$SWARMS_ENV_FILE" ]]; then
set -a
. "$SWARMS_ENV_FILE"
set +a
fi
export NANOBOT_AUTH_FILE export NANOBOT_AUTH_FILE
export NANOBOT_RUNTIME_ENV_FILE export NANOBOT_RUNTIME_ENV_FILE
export PORT="$APP_FRONTEND_PORT" export PORT="$APP_FRONTEND_PORT"

View File

@ -264,7 +264,6 @@ export interface UiAgentDescriptor {
protocol: string | null; protocol: string | null;
tags: string[]; tags: string[];
aliases: string[]; aliases: string[];
support_group: boolean;
support_streaming: boolean; support_streaming: boolean;
} }
@ -611,7 +610,6 @@ MCP 页面建议分两块:
- protocol - protocol
- tags - tags
- aliases - aliases
- support_group
- support_streaming - support_streaming
- endpoint / base_url / card_url - endpoint / base_url / card_url
@ -790,4 +788,3 @@ MCP 页面建议分两块:
- `nanobot/a2a/client.py` - `nanobot/a2a/client.py`
- `nanobot/agent/tools/mcp.py` - `nanobot/agent/tools/mcp.py`
- `nanobot/web/server.py` - `nanobot/web/server.py`

View File

@ -35,6 +35,9 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
const EMPTY_AGENT_FORM = { const EMPTY_AGENT_FORM = {
id: '', id: '',
@ -70,7 +73,7 @@ function formatJson(value: Record<string, unknown>): string {
return JSON.stringify(value, null, 2); return JSON.stringify(value, null, 2);
} }
function parseJsonObject(raw: string, label: string): Record<string, unknown> { function parseJsonObject(raw: string, label: string, locale: AppLocale): Record<string, unknown> {
const probe = raw.trim(); const probe = raw.trim();
if (!probe) { if (!probe) {
return {}; return {};
@ -79,25 +82,46 @@ function parseJsonObject(raw: string, label: string): Record<string, unknown> {
try { try {
parsed = JSON.parse(probe); parsed = JSON.parse(probe);
} catch { } catch {
throw new Error(`${label} 需要是合法 JSON`); throw new Error(`${label} ${pickAppText(locale, '需要是合法 JSON', 'must be valid JSON')}`);
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 需要是 JSON 对象`); throw new Error(`${label} ${pickAppText(locale, '需要是 JSON 对象', 'must be a JSON object')}`);
} }
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} }
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> { function parseNestedJsonObject(raw: string, label: string, locale: AppLocale): Record<string, Record<string, unknown>> {
const parsed = parseJsonObject(raw, label); const parsed = parseJsonObject(raw, label, locale);
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`); throw new Error(
pickAppText(
locale,
`${label} 中的 ${key} 必须是 JSON 对象`,
`${key} in ${label} must be a JSON object`
)
);
} }
} }
return parsed as Record<string, Record<string, unknown>>; return parsed as Record<string, Record<string, unknown>>;
} }
function agentSourceLabel(source: UiAgentDescriptor['source'], locale: AppLocale): string {
switch (source) {
case 'workspace':
return pickAppText(locale, '工作区', 'Workspace');
case 'plugin':
return pickAppText(locale, '插件', 'Plugin');
case 'skill':
return pickAppText(locale, '技能', 'Skill');
default:
return pickAppText(locale, '内置', 'Built-in');
}
}
export default function AgentsPage() { export default function AgentsPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
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);
@ -133,7 +157,7 @@ export default function AgentsPage() {
setSubagents(nextSubagents); setSubagents(nextSubagents);
setCachedAgents(nextAgents); setCachedAgents(nextAgents);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载智能体失败'); setError(err.message || t('加载智能体失败', 'Failed to load agents'));
} finally { } finally {
if (background) { if (background) {
setRefreshing(false); setRefreshing(false);
@ -161,7 +185,7 @@ export default function AgentsPage() {
setSubagents(nextSubagents); setSubagents(nextSubagents);
setCachedAgents(nextAgents); setCachedAgents(nextAgents);
} catch (err: any) { } catch (err: any) {
setError(err.message || '刷新智能体失败'); setError(err.message || t('刷新智能体失败', 'Failed to refresh agents'));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@ -188,7 +212,7 @@ export default function AgentsPage() {
e.preventDefault(); e.preventDefault();
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.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(t('请至少填写 A2A 部署地址、接口地址或卡片地址', 'Enter at least an A2A base URL, endpoint, or card URL'));
return; return;
} }
setAgentSubmitting(true); setAgentSubmitting(true);
@ -214,7 +238,7 @@ export default function AgentsPage() {
handleAgentDialogOpenChange(false); handleAgentDialogOpenChange(false);
await load(true); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '新增智能体失败'); setError(err.message || t('新增智能体失败', 'Failed to create the agent'));
} finally { } finally {
setAgentSubmitting(false); setAgentSubmitting(false);
} }
@ -225,7 +249,7 @@ export default function AgentsPage() {
await deleteAgent(agentId); await deleteAgent(agentId);
await load(true); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '删除智能体失败'); setError(err.message || t('删除智能体失败', 'Failed to delete the agent'));
} }
}; };
@ -251,7 +275,7 @@ export default function AgentsPage() {
const handleSaveSubagent = async (e: React.FormEvent) => { const handleSaveSubagent = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!subagentForm.id.trim()) { if (!subagentForm.id.trim()) {
setError('Sub-agent ID 不能为空'); setError(t('Sub-agent ID 不能为空', 'Sub-agent ID cannot be empty'));
return; return;
} }
setSubagentSubmitting(true); setSubagentSubmitting(true);
@ -268,8 +292,8 @@ export default function AgentsPage() {
allow_mcp: subagentForm.allow_mcp, allow_mcp: subagentForm.allow_mcp,
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean), tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean), aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'), metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata', locale),
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'), mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers', locale),
}; };
if (editingSubagentId) { if (editingSubagentId) {
await updateSubagent(editingSubagentId, payload); await updateSubagent(editingSubagentId, payload);
@ -279,7 +303,7 @@ export default function AgentsPage() {
handleSubagentDialogOpenChange(false); handleSubagentDialogOpenChange(false);
await load(true); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '保存 Sub-Agent 失败'); setError(err.message || t('保存 Sub-Agent 失败', 'Failed to save the sub-agent'));
} finally { } finally {
setSubagentSubmitting(false); setSubagentSubmitting(false);
} }
@ -290,7 +314,7 @@ export default function AgentsPage() {
await deleteSubagent(subagentId); await deleteSubagent(subagentId);
await load(true); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '删除 Sub-Agent 失败'); setError(err.message || t('删除 Sub-Agent 失败', 'Failed to delete the sub-agent'));
} }
}; };
@ -308,47 +332,47 @@ export default function AgentsPage() {
<div> <div>
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" /> <Bot className="w-6 h-6" />
{t('智能体', 'Agents')}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
A2A Sub-Agent {t('管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。', 'Manage external A2A agents and persistent local sub-agents.')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <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' : ''}`} />
{t('刷新', 'Refresh')}
</Button> </Button>
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}> <Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{t('新增智能体', 'Add agent')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t('新增工作区智能体', 'Add workspace agent')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={handleCreateAgent}> <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">{t('A2A 部署地址', 'A2A base URL')}</Label>
<Input <Input
id="base_url" id="base_url"
value={agentForm.base_url} value={agentForm.base_url}
onChange={(e) => setAgentForm((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={t('https://agent.example.com 或 agent.example.com:19090', 'https://agent.example.com or agent.example.com:19090')}
/> />
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{t('默认只需要填写部署地址。保存时会自动读取', 'Usually the base URL is enough. Save will auto-read')}
<code className="mx-1">/.well-known</code> <code className="mx-1">/.well-known</code>
card {t('路径并补齐 card 信息。', 'and complete the card metadata.')}
</p> </p>
</div> </div>
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}> <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">
{t('高级设置(可选)', 'Advanced settings (optional)')}
<ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
@ -359,27 +383,27 @@ export default function AgentsPage() {
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} /> <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">{t('名称', 'Name')}</Label>
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} /> <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">{t('描述', 'Description')}</Label>
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} /> <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">{t('接口地址', 'Endpoint URL')}</Label>
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} /> <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">{t('卡片地址', 'Card URL')}</Label>
<Input id="card_url" value={agentForm.card_url} onChange={(e) => setAgentForm((s) => ({ ...s, card_url: e.target.value }))} /> <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">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_mode"></Label> <Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
<select <select
id="auth_mode" id="auth_mode"
value={agentForm.auth_mode} value={agentForm.auth_mode}
@ -400,31 +424,34 @@ export default function AgentsPage() {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_env"></Label> <Label htmlFor="auth_env">{t('认证环境变量', 'Credential env var')}</Label>
<Input id="auth_env" value={agentForm.auth_env} onChange={(e) => setAgentForm((s) => ({ ...s, auth_env: e.target.value }))} /> <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">{t('标签', 'Tags')}</Label>
<Input id="tags" value={agentForm.tags} onChange={(e) => setAgentForm((s) => ({ ...s, tags: e.target.value }))} /> <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">{t('别名', 'Aliases')}</Label>
<Input id="aliases" value={agentForm.aliases} onChange={(e) => setAgentForm((s) => ({ ...s, aliases: e.target.value }))} /> <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">
Sub-Agent Sub-Agent registry {t(
'如果这是持久化本地 Sub-Agent请改用下面的 Sub-Agent 面板,不要在这里单独删除 registry 记录。',
'If this is a persistent local sub-agent, manage it from the sub-agent panel below instead of deleting the registry entry here.'
)}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
{t('取消', 'Cancel')}
</Button> </Button>
<Button type="submit" disabled={agentSubmitting}> <Button type="submit" disabled={agentSubmitting}>
{agentSubmitting ? <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" />}
{t('保存', 'Save')}
</Button> </Button>
</div> </div>
</form> </form>
@ -434,12 +461,12 @@ export default function AgentsPage() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Sub-Agent {t('新增 Sub-Agent', 'Add sub-agent')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingSubagentId ? '编辑 Sub-Agent' : '新增 Persistent Sub-Agent'}</DialogTitle> <DialogTitle>{editingSubagentId ? t('编辑 Sub-Agent', 'Edit sub-agent') : t('新增 Persistent Sub-Agent', 'Create persistent sub-agent')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={handleSaveSubagent}> <form className="space-y-4" onSubmit={handleSaveSubagent}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -454,7 +481,7 @@ export default function AgentsPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subagent_name"></Label> <Label htmlFor="subagent_name">{t('名称', 'Name')}</Label>
<Input <Input
id="subagent_name" id="subagent_name"
value={subagentForm.name} value={subagentForm.name}
@ -464,13 +491,13 @@ export default function AgentsPage() {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subagent_description"></Label> <Label htmlFor="subagent_description">{t('描述', 'Description')}</Label>
<Textarea <Textarea
id="subagent_description" id="subagent_description"
rows={3} rows={3}
value={subagentForm.description} value={subagentForm.description}
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))} onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
placeholder="用于研究和资料整理的本地持久化子智能体" placeholder={t('用于研究和资料整理的本地持久化子智能体', 'A persistent local sub-agent for research and note taking')}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -485,16 +512,16 @@ export default function AgentsPage() {
</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="subagent_model"></Label> <Label htmlFor="subagent_model">{t('模型', 'Model')}</Label>
<Input <Input
id="subagent_model" id="subagent_model"
value={subagentForm.model} value={subagentForm.model}
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))} onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
placeholder="留空则继承主 Agent 默认模型" placeholder={t('留空则继承主 Agent 默认模型', 'Leave blank to inherit the lead agent model')}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="delegation_mode"></Label> <Label htmlFor="delegation_mode">{t('委派模式', 'Delegation mode')}</Label>
<select <select
id="delegation_mode" id="delegation_mode"
value={subagentForm.delegation_mode} value={subagentForm.delegation_mode}
@ -509,8 +536,8 @@ export default function AgentsPage() {
<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="flex items-center justify-between rounded-md border border-border/70 px-3 py-2"> <div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div> <div>
<Label htmlFor="subagent_enabled"></Label> <Label htmlFor="subagent_enabled">{t('启用', 'Enabled')}</Label>
<p className="text-xs text-muted-foreground mt-1"> workspace </p> <p className="text-xs text-muted-foreground mt-1">{t('关闭后仍保留 workspace 和配置', 'Turning this off keeps the workspace and config intact')}</p>
</div> </div>
<Switch <Switch
id="subagent_enabled" id="subagent_enabled"
@ -520,8 +547,8 @@ export default function AgentsPage() {
</div> </div>
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2"> <div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div> <div>
<Label htmlFor="subagent_allow_mcp"> MCP</Label> <Label htmlFor="subagent_allow_mcp">{t('允许 MCP', 'Allow MCP')}</Label>
<p className="text-xs text-muted-foreground mt-1"> MCP </p> <p className="text-xs text-muted-foreground mt-1">{t('保留 MCP 配置并在运行时接入', 'Keep MCP config and attach it at runtime')}</p>
</div> </div>
<Switch <Switch
id="subagent_allow_mcp" id="subagent_allow_mcp"
@ -532,7 +559,7 @@ export default function AgentsPage() {
</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="subagent_tags"></Label> <Label htmlFor="subagent_tags">{t('标签', 'Tags')}</Label>
<Input <Input
id="subagent_tags" id="subagent_tags"
value={subagentForm.tags} value={subagentForm.tags}
@ -541,7 +568,7 @@ export default function AgentsPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subagent_aliases"></Label> <Label htmlFor="subagent_aliases">{t('别名', 'Aliases')}</Label>
<Input <Input
id="subagent_aliases" id="subagent_aliases"
value={subagentForm.aliases} value={subagentForm.aliases}
@ -553,7 +580,7 @@ export default function AgentsPage() {
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}> <Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between"> <Button type="button" variant="outline" className="w-full justify-between">
JSON Metadata / MCP {t('原始 JSON 设置Metadata / MCP', 'Raw JSON settings (Metadata / MCP)')}
<ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
@ -579,19 +606,19 @@ export default function AgentsPage() {
</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">
workspace {t('创建后会自动生成独立 workspace、写入', 'Creating this will generate an isolated workspace and write')}
<code className="mx-1">AGENTS.json</code> <code className="mx-1">AGENTS.json</code>
{t('和', 'and')}
<code className="mx-1">AGENTS.md</code> <code className="mx-1">AGENTS.md</code>
{t(',并注册到工作区智能体列表。', 'and register it in the workspace agent registry.')}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
{t('取消', 'Cancel')}
</Button> </Button>
<Button type="submit" disabled={subagentSubmitting}> <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" />} {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 ? '更新' : '创建'} {editingSubagentId ? t('更新', 'Update') : t('创建', 'Create')}
</Button> </Button>
</div> </div>
</form> </form>
@ -613,8 +640,8 @@ export default function AgentsPage() {
<Tabs defaultValue="agents" className="space-y-4"> <Tabs defaultValue="agents" className="space-y-4">
<TabsList> <TabsList>
<TabsTrigger value="agents"></TabsTrigger> <TabsTrigger value="agents">{t('委派目标', 'Delegation targets')}</TabsTrigger>
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger> <TabsTrigger value="subagents">{t('Persistent Sub-Agents', 'Persistent sub-agents')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="agents" className="space-y-4"> <TabsContent value="agents" className="space-y-4">
@ -630,25 +657,24 @@ export default function AgentsPage() {
<CardTitle className="text-base truncate">{agent.name}</CardTitle> <CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p> <p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed"> <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'} {agent.description || t('暂无描述', 'No description')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap justify-end"> <div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge> <Badge variant="outline">{agentSourceLabel(agent.source, locale)}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge> <Badge variant="secondary">{agent.protocol || t('本地', 'Local')}</Badge>
{isManagedSubagent && <Badge className="bg-amber-600"> Sub-Agent</Badge>} {isManagedSubagent && <Badge className="bg-amber-600">{t('受管 Sub-Agent', 'Managed sub-agent')}</Badge>}
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>} {agent.support_streaming && <Badge className="bg-sky-600">{t('流式', 'Streaming')}</Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground"> <div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>} {agent.base_url && <div><span className="font-medium text-foreground">{t('基础地址:', 'Base URL:')}</span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>} {agent.endpoint && <div><span className="font-medium text-foreground">{t('接口地址:', 'Endpoint:')}</span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>} {agent.card_url && <div><span className="font-medium text-foreground">{t('卡片地址:', 'Card URL:')}</span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>} {agent.auth_env && <div><span className="font-medium text-foreground">{t('认证变量:', 'Auth env:')}</span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>} {agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">{t('鉴权模式:', 'Auth mode:')}</span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>} {agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>} {(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div> </div>
@ -664,7 +690,7 @@ export default function AgentsPage() {
)} )}
{agent.aliases.length > 0 && ( {agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span> <span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
{agent.aliases.map((alias) => ( {agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code> <code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))} ))}
@ -674,14 +700,14 @@ export default function AgentsPage() {
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
{isManagedSubagent ? ( {isManagedSubagent ? (
<span className="text-xs text-muted-foreground"> Sub-Agent </span> <span className="text-xs text-muted-foreground">{t('请在 Sub-Agent 面板管理', 'Manage this in the sub-agent panel')}</span>
) : isWorkspace ? ( ) : isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}> <Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button> </Button>
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground">{t('只读来源', 'Read-only source')}</span>
)} )}
</div> </div>
</CardContent> </CardContent>
@ -694,12 +720,11 @@ export default function AgentsPage() {
<TabsContent value="subagents" className="space-y-4"> <TabsContent value="subagents" className="space-y-4">
<Card className="border-border/70 bg-muted/20"> <Card className="border-border/70 bg-muted/20">
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed"> <CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
Sub-Agent {t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')}
<code className="mx-1">~/.nanobot/workspace/agents/&lt;id&gt;_agent</code> <code className="mx-1">~/.nanobot/workspace/agents/&lt;id&gt;_agent</code>
workspace`AGENTS.json``AGENTS.md`skills memory {t('下拥有自己的 workspace、`AGENTS.json`、`AGENTS.md`、skills 和 memory。默认委派模式是', ', plus `AGENTS.json`, `AGENTS.md`, skills, and memory. The default delegation mode is')}
<code className="mx-1">remote_a2a_only</code> <code className="mx-1">remote_a2a_only</code>
A2A agent {t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}
</CardContent> </CardContent>
</Card> </Card>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
@ -711,12 +736,12 @@ export default function AgentsPage() {
<CardTitle className="text-base truncate">{subagent.name}</CardTitle> <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-xs text-muted-foreground mt-1 font-mono">{subagent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed"> <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{subagent.description || '—'} {subagent.description || t('暂无描述', 'No description')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap justify-end"> <div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant={subagent.enabled ? 'default' : 'outline'}> <Badge variant={subagent.enabled ? 'default' : 'outline'}>
{subagent.enabled ? '启用' : '停用'} {subagent.enabled ? t('启用', 'Enabled') : t('停用', 'Disabled')}
</Badge> </Badge>
<Badge variant="secondary">{subagent.delegation_mode}</Badge> <Badge variant="secondary">{subagent.delegation_mode}</Badge>
{subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>} {subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>}
@ -726,11 +751,11 @@ export default function AgentsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground"> <div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
<div><span className="font-medium text-foreground">Workspace</span> {subagent.workspace}</div> <div><span className="font-medium text-foreground">{t('Workspace', '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">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">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">Card</span> {subagent.card_url}</div>
<div><span className="font-medium text-foreground">MCP Servers</span> {Object.keys(subagent.mcp_servers || {}).length}</div> <div><span className="font-medium text-foreground">{t('MCP Servers', 'MCP servers:')}</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
</div> </div>
{subagent.system_prompt && ( {subagent.system_prompt && (
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2"> <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
@ -752,7 +777,7 @@ export default function AgentsPage() {
)} )}
{subagent.aliases.length > 0 && ( {subagent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span> <span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
{subagent.aliases.map((alias) => ( {subagent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code> <code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))} ))}
@ -763,11 +788,11 @@ export default function AgentsPage() {
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}> <Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
{t('编辑', 'Edit')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}> <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" />
{t('删除', 'Delete')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -777,7 +802,7 @@ export default function AgentsPage() {
{subagents.length === 0 && ( {subagents.length === 0 && (
<Card> <Card>
<CardContent className="pt-6 text-sm text-muted-foreground"> <CardContent className="pt-6 text-sm text-muted-foreground">
Sub-Agent Sub-Agent {t('还没有持久化 Sub-Agent。点击右上角“新增 Sub-Agent”开始创建。', 'There are no persistent sub-agents yet. Use "Add sub-agent" in the top-right corner to create one.')}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@ -40,10 +40,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
import type { CronJob } from '@/types'; import type { CronJob } from '@/types';
export default function CronPage() { export default function CronPage() {
const { locale } = useAppI18n();
const sessionId = useChatStore((s) => s.sessionId); const sessionId = useChatStore((s) => s.sessionId);
const [jobs, setJobs] = useState<CronJob[]>([]); const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -58,7 +61,7 @@ export default function CronPage() {
const data = await listCronJobs(true); const data = await listCronJobs(true);
setJobs(data); setJobs(data);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载任务失败'); setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -138,16 +141,16 @@ export default function CronPage() {
<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" />
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={loadJobs} variant="outline" size="sm"> <Button onClick={loadJobs} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button> </Button>
<Button onClick={() => setShowAdd(true)} size="sm"> <Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '新建任务', 'New job')}
</Button> </Button>
</div> </div>
</div> </div>
@ -178,21 +181,21 @@ export default function CronPage() {
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" /> <Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p> <p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
<p className="text-sm mt-1"></p> <p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-16"></TableHead> <TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24"></TableHead> <TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -238,7 +241,7 @@ export default function CronPage() {
)} )}
{job.last_status === 'error' && ( {job.last_status === 'error' && (
<Badge variant="destructive" className="text-xs"> <Badge variant="destructive" className="text-xs">
{pickAppText(locale, '错误', 'Error')}
</Badge> </Badge>
)} )}
{!job.last_status && ( {!job.last_status && (
@ -254,7 +257,7 @@ export default function CronPage() {
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleRun(job.id)} onClick={() => handleRun(job.id)}
title="立即执行" title={pickAppText(locale, '立即执行', 'Run now')}
> >
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
</Button> </Button>
@ -263,7 +266,7 @@ export default function CronPage() {
size="icon" size="icon"
className="h-7 w-7 text-destructive hover:text-destructive" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(job.id)} onClick={() => handleDelete(job.id)}
title="删除" title={pickAppText(locale, '删除', 'Delete')}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</Button> </Button>
@ -294,6 +297,7 @@ function AddJobForm({
}) => void; }) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { locale } = useAppI18n();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every'); const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
@ -317,7 +321,7 @@ function AddJobForm({
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
@ -327,16 +331,16 @@ function AddJobForm({
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"></Label> <Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
<Input <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="例如:日报汇总" placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="schedule-type"></Label> <Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
<Select <Select
value={scheduleType} value={scheduleType}
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')} onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
@ -345,8 +349,8 @@ function AddJobForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="every"> N </SelectItem> <SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
<SelectItem value="cron">Cron </SelectItem> <SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -354,7 +358,7 @@ function AddJobForm({
{scheduleType === 'every' ? ( {scheduleType === 'every' ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="every"></Label> <Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
<Input <Input
id="every" id="every"
type="number" type="number"
@ -365,15 +369,15 @@ function AddJobForm({
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{parseInt(everySeconds, 10) >= 3600 {parseInt(everySeconds, 10) >= 3600
? `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}` ? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
: parseInt(everySeconds, 10) >= 60 : parseInt(everySeconds, 10) >= 60
? `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}` ? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
: ''} : ''}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cron">Cron </Label> <Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
<Input <Input
id="cron" id="cron"
value={cronExpr} value={cronExpr}
@ -381,31 +385,31 @@ function AddJobForm({
placeholder="0 9 * * *" placeholder="0 9 * * *"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
</p> </p>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="message"></Label> <Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
<Input <Input
id="message" id="message"
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="例如:检查我的邮件并生成摘要" placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Web <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code> {pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
</p> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}> <Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '创建任务', 'Create job')}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -29,8 +29,11 @@ import {
import type { WorkspaceItem } from '@/lib/api'; import type { WorkspaceItem } from '@/lib/api';
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';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function FilesPage() { export default function FilesPage() {
const { locale } = useAppI18n();
const [items, setItems] = useState<WorkspaceItem[]>([]); const [items, setItems] = useState<WorkspaceItem[]>([]);
const [currentPath, setCurrentPath] = useState(''); const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -63,8 +66,14 @@ export default function FilesPage() {
}; };
const handleDelete = async (item: WorkspaceItem) => { const handleDelete = async (item: WorkspaceItem) => {
const label = item.type === 'directory' ? '文件夹' : '文件'; const label = item.type === 'directory'
if (!confirm(`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件' : ''}`)) { ? pickAppText(locale, '文件', 'folder')
: pickAppText(locale, '文件', 'file');
if (!confirm(pickAppText(
locale,
`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件)' : ''}`,
`Delete ${label} "${item.name}"?${item.type === 'directory' ? ' (including all nested files)' : ''}`
))) {
return; return;
} }
try { try {
@ -144,7 +153,7 @@ export default function FilesPage() {
const formatDate = (iso: string) => { const formatDate = (iso: string) => {
try { try {
return new Date(iso).toLocaleString('zh-CN', { return new Date(iso).toLocaleString(locale, {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
@ -159,7 +168,7 @@ export default function FilesPage() {
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
@ -168,7 +177,7 @@ export default function FilesPage() {
disabled={loading} disabled={loading}
> >
<FolderPlus className="w-4 h-4 mr-1" /> <FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -184,7 +193,7 @@ export default function FilesPage() {
) : ( ) : (
<> <>
<Upload className="w-4 h-4 mr-1" /> <Upload className="w-4 h-4 mr-1" />
{pickAppText(locale, '上传', 'Upload')}
</> </>
)} )}
</Button> </Button>
@ -212,7 +221,7 @@ export default function FilesPage() {
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent" className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
> >
<Home className="w-3.5 h-3.5" /> <Home className="w-3.5 h-3.5" />
{pickAppText(locale, '工作区', 'Workspace')}
</button> </button>
{breadcrumbs.map((segment, idx) => { {breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/'); const path = breadcrumbs.slice(0, idx + 1).join('/');
@ -251,12 +260,12 @@ export default function FilesPage() {
setNewDirName(''); setNewDirName('');
} }
}} }}
placeholder="文件夹名称" placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring" className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus autoFocus
/> />
<Button size="sm" onClick={handleCreateDir}> <Button size="sm" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -266,7 +275,7 @@ export default function FilesPage() {
setNewDirName(''); setNewDirName('');
}} }}
> >
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
</div> </div>
)} )}
@ -279,8 +288,8 @@ export default function FilesPage() {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<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">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" /> <FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium"></p> <p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">"上传""新建文件夹"使</p> <p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div> </div>
) : ( ) : (
<ScrollArea className="h-[calc(100vh-14rem)]"> <ScrollArea className="h-[calc(100vh-14rem)]">
@ -330,7 +339,7 @@ export default function FilesPage() {
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleDownload(item)} onClick={() => handleDownload(item)}
title="下载" title={pickAppText(locale, '下载', 'Download')}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
@ -340,7 +349,7 @@ export default function FilesPage() {
size="icon" size="icon"
className="h-7 w-7 text-destructive hover:text-destructive" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(item)} onClick={() => handleDelete(item)}
title="删除" title={pickAppText(locale, '删除', 'Delete')}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>

View File

@ -1,168 +0,0 @@
'use client';
import React, { useState } from 'react';
import {
MessageSquare,
Terminal,
Layers,
Wifi,
WifiOff,
Plus,
Trash2,
Send,
ChevronDown,
ChevronRight,
} from 'lucide-react';
interface SectionProps {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
function Section({ icon, title, children, defaultOpen = false }: SectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
className="w-full flex items-center gap-3 px-4 py-3 bg-card hover:bg-accent/50 transition-colors text-left"
onClick={() => setOpen((v) => !v)}
>
<span className="text-primary">{icon}</span>
<span className="font-medium flex-1">{title}</span>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 py-4 space-y-3 text-sm text-muted-foreground border-t border-border bg-background">
{children}
</div>
)}
</div>
);
}
function Tag({ children, color = 'default' }: { children: React.ReactNode; color?: 'green' | 'yellow' | 'red' | 'default' }) {
const cls = {
green: 'bg-green-900/30 text-green-400 border-green-800',
yellow: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
red: 'bg-red-900/30 text-red-400 border-red-800',
default: 'bg-muted text-foreground border-border',
}[color];
return (
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-mono ${cls}`}>
{children}
</span>
);
}
export default function HelpPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-1">使</h1>
<p className="text-muted-foreground text-sm">使 Boardware Agent Sandbox </p>
</div>
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
<ol className="list-decimal list-inside space-y-1.5 ml-1">
<li></li>
<li> <Tag>Enter</Tag> <Tag>Shift + Enter</Tag> </li>
<li> Boardware Agent Sandbox "思考中..."</li>
</ol>
<p className="mt-1">
<Tag></Tag>
</p>
</Section>
<Section icon={<Terminal className="w-5 h-5" />} title="斜杠命令(/命令)">
<p> <Tag>/</Tag> </p>
<ul className="space-y-1.5 ml-1">
<li> <Tag>/</Tag> </li>
<li> <Tag></Tag> <Tag></Tag> </li>
<li> <Tag>Enter</Tag> <Tag>Tab</Tag> </li>
<li> <Tag>Esc</Tag> </li>
</ul>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
</Section>
<Section icon={<Layers className="w-5 h-5" />} title="对话管理">
<ul className="space-y-2 ml-1">
<li>
<span className="inline-flex items-center gap-1"><Plus className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><Trash2 className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
</ul>
<p className="text-xs mt-1 text-muted-foreground/70"></p>
</Section>
<Section icon={<Wifi className="w-5 h-5" />} title="连接状态说明">
<p></p>
<div className="space-y-2 ml-1 mt-2">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Tag color="green"></Tag>
<span> Boardware Agent Sandbox </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
<Tag color="yellow"> / </Tag>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red">线</Tag>
<span> Boardware Agent Sandbox </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red"></Tag>
<span> </span>
</div>
</div>
<p className="mt-3 text-xs">
<strong className="text-foreground"></strong>"服务离线"
</p>
</Section>
<Section icon={<Send className="w-5 h-5" />} title="输入技巧">
<ul className="space-y-2 ml-1">
<li><Tag>Enter</Tag> </li>
<li><Tag>Shift + Enter</Tag> </li>
<li>使 Enter Enter </li>
<li> <Tag>/</Tag> </li>
</ul>
</Section>
<Section icon={<WifiOff className="w-5 h-5" />} title="常见问题">
<div className="space-y-4">
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p>"已连接""服务离线""未连接"</p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q Boardware Agent Sandbox </p>
<p><strong className="text-foreground"></strong>AI </p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q使</p>
<p><strong className="text-foreground"></strong></p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p><strong className="text-foreground"></strong> Boardware Agent Sandbox <strong className="text-foreground"></strong> Agent </p>
</div>
</div>
</Section>
</div>
);
}

View File

@ -1,5 +1,6 @@
import Header from '@/components/Header'; import Header from '@/components/Header';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard';
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
export default function AppLayout({ export default function AppLayout({
children, children,
@ -10,7 +11,10 @@ export default function AppLayout({
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<Header /> <Header />
<main className="pt-16"> <main className="pt-16">
<AuthGuard>{children}</AuthGuard> <AuthGuard>
<AppRuntimeBridge />
{children}
</AuthGuard>
</main> </main>
</div> </div>
); );

View File

@ -28,8 +28,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import type { Marketplace, MarketplacePlugin } from '@/types'; import type { Marketplace, MarketplacePlugin } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function MarketplacePage() { export default function MarketplacePage() {
const { locale } = useAppI18n();
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]); const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null); const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]); const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
@ -60,7 +63,7 @@ export default function MarketplacePage() {
setPlugins([]); setPlugins([]);
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载市场失败'); setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -72,7 +75,7 @@ export default function MarketplacePage() {
const data = await listMarketplacePlugins(marketplaceName); const data = await listMarketplacePlugins(marketplaceName);
setPlugins(Array.isArray(data) ? data : []); setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载插件失败'); setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
} finally { } finally {
setPluginsLoading(false); setPluginsLoading(false);
} }
@ -99,7 +102,7 @@ export default function MarketplacePage() {
await loadMarketplaces(); await loadMarketplaces();
setSelectedMarketplace(marketplace.name); setSelectedMarketplace(marketplace.name);
} catch (err: any) { } catch (err: any) {
setError(err.message || '添加市场失败'); setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
} finally { } finally {
setAdding(false); setAdding(false);
} }
@ -115,7 +118,7 @@ export default function MarketplacePage() {
} }
await loadMarketplaces(); await loadMarketplaces();
} catch (err: any) { } catch (err: any) {
setError(err.message || '移除市场失败'); setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
} }
}; };
@ -126,7 +129,7 @@ export default function MarketplacePage() {
await updateMarketplace(name); await updateMarketplace(name);
await loadPlugins(name); await loadPlugins(name);
} catch (err: any) { } catch (err: any) {
setError(err.message || '更新市场失败'); setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
} finally { } finally {
setUpdatingMarketplace(null); setUpdatingMarketplace(null);
} }
@ -139,7 +142,7 @@ export default function MarketplacePage() {
await installMarketplacePlugin(marketplaceName, pluginName); await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName); await loadPlugins(marketplaceName);
} catch (err: any) { } catch (err: any) {
setError(err.message || '更新插件失败'); setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
} finally { } finally {
setActionPlugin(null); setActionPlugin(null);
} }
@ -152,7 +155,7 @@ export default function MarketplacePage() {
await installMarketplacePlugin(marketplaceName, pluginName); await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName); await loadPlugins(marketplaceName);
} catch (err: any) { } catch (err: any) {
setError(err.message || '安装插件失败'); setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
} finally { } finally {
setActionPlugin(null); setActionPlugin(null);
} }
@ -167,7 +170,7 @@ export default function MarketplacePage() {
await loadPlugins(selectedMarketplace); await loadPlugins(selectedMarketplace);
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || '卸载插件失败'); setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
} finally { } finally {
setActionPlugin(null); setActionPlugin(null);
} }
@ -195,10 +198,10 @@ export default function MarketplacePage() {
<div> <div>
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Store className="w-6 h-6" /> <Store className="w-6 h-6" />
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -208,11 +211,11 @@ export default function MarketplacePage() {
size="sm" size="sm"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '添加市场', 'Add marketplace')}
</Button> </Button>
<Button onClick={handleRefresh} variant="outline" size="sm"> <Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@ -245,7 +248,7 @@ export default function MarketplacePage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/..." placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')}
value={addSource} value={addSource}
onChange={(e) => setAddSource(e.target.value)} onChange={(e) => setAddSource(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -260,7 +263,7 @@ export default function MarketplacePage() {
) : ( ) : (
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
)} )}
{pickAppText(locale, '添加', 'Add')}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@ -270,7 +273,7 @@ export default function MarketplacePage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
> >
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -301,7 +304,7 @@ export default function MarketplacePage() {
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary" className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
disabled={updatingMarketplace === marketplace.name} disabled={updatingMarketplace === marketplace.name}
onClick={() => handleUpdateMarketplace(marketplace.name)} onClick={() => handleUpdateMarketplace(marketplace.name)}
title="更新市场" title={pickAppText(locale, '更新市场', 'Update marketplace')}
> >
{updatingMarketplace === marketplace.name ? ( {updatingMarketplace === marketplace.name ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
@ -327,9 +330,9 @@ export default function MarketplacePage() {
<Card> <Card>
<CardContent className="py-16 text-center text-muted-foreground"> <CardContent className="py-16 text-center text-muted-foreground">
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" /> <Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p> <p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
<p className="text-sm mt-2 max-w-sm mx-auto"> <p className="text-sm mt-2 max-w-sm mx-auto">
<strong></strong> Git 使 {pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -346,8 +349,8 @@ export default function MarketplacePage() {
<Card> <Card>
<CardContent className="py-12 text-center text-muted-foreground"> <CardContent className="py-12 text-center text-muted-foreground">
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" /> <Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p> <p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
<p className="text-sm mt-1"></p> <p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -364,7 +367,7 @@ export default function MarketplacePage() {
{plugin.installed && ( {plugin.installed && (
<Badge variant="secondary" className="text-xs gap-1"> <Badge variant="secondary" className="text-xs gap-1">
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
{pickAppText(locale, '已安装', 'Installed')}
</Badge> </Badge>
)} )}
</div> </div>
@ -390,7 +393,7 @@ export default function MarketplacePage() {
) : ( ) : (
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
)} )}
{pickAppText(locale, '更新', 'Update')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -403,7 +406,7 @@ export default function MarketplacePage() {
) : ( ) : (
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
)} )}
{pickAppText(locale, '卸载', 'Uninstall')}
</Button> </Button>
</> </>
) : ( ) : (
@ -420,7 +423,7 @@ export default function MarketplacePage() {
) : ( ) : (
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
)} )}
{pickAppText(locale, '安装', 'Install')}
</Button> </Button>
)} )}
</div> </div>

View File

@ -15,6 +15,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type McpFormMode = 'remote' | 'install'; type McpFormMode = 'remote' | 'install';
@ -81,20 +84,22 @@ function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string
}; };
} }
function serverStatusLabel(status?: string | null) { function serverStatusLabel(status: string | null | undefined, locale: AppLocale) {
if (status === 'connected') return '已连接'; if (status === 'connected') return pickAppText(locale, '已连接', 'Connected');
if (status === 'error') return '异常'; if (status === 'error') return pickAppText(locale, '异常', 'Error');
if (status === 'disconnected' || !status) return '未连接'; if (status === 'disconnected' || !status) return pickAppText(locale, '未连接', 'Disconnected');
return status; return status;
} }
function transportLabel(transport?: string) { function transportLabel(transport: string | undefined, locale: AppLocale) {
if (transport === 'stdio') return '标准输入输出'; if (transport === 'stdio') return pickAppText(locale, '标准输入输出', 'Standard I/O');
if (transport === 'http') return 'HTTP'; if (transport === 'http') return 'HTTP';
return transport || '-'; return transport || '-';
} }
export default function MCPPage() { export default function MCPPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const cachedServers = useChatStore((s) => s.mcpRegistry); const cachedServers = useChatStore((s) => s.mcpRegistry);
const cachedTools = useChatStore((s) => s.mcpToolRegistry); const cachedTools = useChatStore((s) => s.mcpToolRegistry);
const setCachedServers = useChatStore((s) => s.setMcpRegistry); const setCachedServers = useChatStore((s) => s.setMcpRegistry);
@ -134,7 +139,7 @@ export default function MCPPage() {
setAuthzStatus(authzData); setAuthzStatus(authzData);
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null)); setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载 MCP 服务失败'); setError(err.message || t('加载 MCP 服务失败', 'Failed to load MCP servers'));
} finally { } finally {
if (background) { if (background) {
setRefreshing(false); setRefreshing(false);
@ -172,7 +177,7 @@ export default function MCPPage() {
if (!value.trim()) return {}; if (!value.trim()) return {};
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`); throw new Error(`${label} ${t('必须是 JSON 对象', 'must be a JSON object')}`);
} }
return parsed as Record<string, string>; return parsed as Record<string, string>;
}; };
@ -187,16 +192,16 @@ export default function MCPPage() {
const command = form.command.trim(); const command = form.command.trim();
const toolTimeout = Number(form.tool_timeout || 30); const toolTimeout = Number(form.tool_timeout || 30);
if (!id) { if (!id) {
throw new Error('ID 不能为空'); throw new Error(t('ID 不能为空', 'ID cannot be empty'));
} }
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) { if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
throw new Error('工具超时必须大于 0'); throw new Error(t('工具超时必须大于 0', 'Tool timeout must be greater than 0'));
} }
if (form.mode === 'remote' && !url) { if (form.mode === 'remote' && !url) {
throw new Error('请输入 MCP Server 地址'); throw new Error(t('请输入 MCP Server 地址', 'Enter an MCP server URL'));
} }
if (form.mode === 'install' && !command) { if (form.mode === 'install' && !command) {
throw new Error('请输入安装或启动命令'); throw new Error(t('请输入安装或启动命令', 'Enter an install or launch command'));
} }
const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none'; const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none';
@ -209,7 +214,7 @@ export default function MCPPage() {
: [], : [],
env: {}, env: {},
url: form.mode === 'remote' ? url : '', url: form.mode === 'remote' ? url : '',
headers: form.mode === 'remote' ? parseObjectField('请求头', form.headers) : {}, headers: form.mode === 'remote' ? parseObjectField(t('请求头', 'Headers'), form.headers) : {},
auth_mode: authMode, auth_mode: authMode,
auth_audience: authAudience, auth_audience: authAudience,
auth_scopes: [], auth_scopes: [],
@ -224,7 +229,7 @@ export default function MCPPage() {
resetForm(); resetForm();
await load(); await load();
} catch (err: any) { } catch (err: any) {
setError(err.message || '保存 MCP 服务失败'); setError(err.message || t('保存 MCP 服务失败', 'Failed to save the MCP server'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -236,7 +241,7 @@ export default function MCPPage() {
setSelectedServerId((current) => (current === serverId ? null : current)); setSelectedServerId((current) => (current === serverId ? null : current));
await load(); await load();
} catch (err: any) { } catch (err: any) {
setError(err.message || '删除 MCP 服务失败'); setError(err.message || t('删除 MCP 服务失败', 'Failed to delete the MCP server'));
} }
}; };
@ -246,7 +251,7 @@ export default function MCPPage() {
await testMcpServer(serverId); await testMcpServer(serverId);
await load(true); await load(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || '测试 MCP 服务失败'); setError(err.message || t('测试 MCP 服务失败', 'Failed to test the MCP server'));
} finally { } finally {
setTestingId(null); setTestingId(null);
} }
@ -257,20 +262,32 @@ export default function MCPPage() {
const showAuthzPreview = form.auth_mode === 'oauth_backend_token'; const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null; const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null; const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
let authzHint = '无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。'; let authzHint = t(
'无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。',
'No manual input is required. The audience is generated from the MCP ID and scopes follow current AuthZ permissions.'
);
if (showAuthzPreview) { if (showAuthzPreview) {
if (!form.id.trim()) { if (!form.id.trim()) {
authzHint = '先填写 MCP IDAudience 会自动生成为 mcp:<id>。'; authzHint = t('先填写 MCP IDAudience 会自动生成为 mcp:<id>。', 'Enter the MCP ID first. The audience will become mcp:<id>.');
} else if (!authzStatus?.enabled) { } else if (!authzStatus?.enabled) {
authzHint = '当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。'; authzHint = t(
'当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。',
'AuthZ is not enabled for this workspace, so oauth_backend_token cannot request access tokens.'
);
} else if (!authzStatus.local_backend.registered) { } else if (!authzStatus.local_backend.registered) {
authzHint = '当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。'; authzHint = t(
'当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。',
'The backend is not registered in AuthZ yet, so permissions and access tokens are unavailable.'
);
} else if (authzStatus.error) { } else if (authzStatus.error) {
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`; authzHint = t(`读取 AuthZ 权限失败:${authzStatus.error}`, `Failed to read AuthZ permissions: ${authzStatus.error}`);
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) { } else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
authzHint = `AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`; authzHint = t(
`AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`,
`AuthZ does not have permissions enabled for ${authAudience || 'this MCP'} yet, so calls will return 403 after saving.`
);
} else { } else {
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`; authzHint = t(`已从 AuthZ 读取到 ${authAudience} 的当前权限。`, `Loaded current permissions for ${authAudience} from AuthZ.`);
} }
} }
@ -288,16 +305,16 @@ export default function MCPPage() {
<div> <div>
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" /> <ServerCog className="w-6 h-6" />
MCP {t('MCP 服务', 'MCP servers')}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
MCP {t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}> <Button variant="outline" size="sm" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button> </Button>
<Dialog open={dialogOpen} onOpenChange={(open) => { <Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open); setDialogOpen(open);
@ -306,12 +323,12 @@ export default function MCPPage() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
MCP {t('新增 MCP', 'Add MCP')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</DialogTitle> <DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -320,7 +337,7 @@ export default function MCPPage() {
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} /> <Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tool_timeout"></Label> <Label htmlFor="tool_timeout">{t('工具超时', 'Tool timeout')}</Label>
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} /> <Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div> </div>
</div> </div>
@ -330,24 +347,24 @@ export default function MCPPage() {
className="space-y-4" className="space-y-4"
> >
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label>{t('接入方式', 'Connection mode')}</Label>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2"> <TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2">
<TabsTrigger <TabsTrigger
value="remote" value="remote"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary" className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
> >
<span className="text-sm font-medium"> MCP Server</span> <span className="text-sm font-medium">{t('连接已有 MCP Server', 'Connect to an existing MCP server')}</span>
<span className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-normal text-muted-foreground">
MCP URL {t('适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。', 'Use this for a remote MCP server that is already deployed. Provide the URL, headers, and auth settings.')}
</span> </span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="install" value="install"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary" className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
> >
<span className="text-sm font-medium"></span> <span className="text-sm font-medium">{t('命令安装并启动', 'Install and launch with a command')}</span>
<span className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-normal text-muted-foreground">
`npx``uvx` MCP {t('适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。', 'Use this when the MCP process runs locally via `npx`, `uvx`, or another command.')}
</span> </span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -355,10 +372,10 @@ export default function MCPPage() {
<TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4"> <TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
MCP Server访 {t('连接一个已经存在的 MCP Server前端只保存访问地址、请求头和鉴权配置。', 'Connect to an existing MCP server. The frontend only stores the address, headers, and auth settings.')}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="url">MCP Server </Label> <Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
<Input <Input
id="url" id="url"
value={form.url} value={form.url}
@ -369,7 +386,7 @@ export default function MCPPage() {
</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">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="auth_mode"></Label> <Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
<select <select
id="auth_mode" id="auth_mode"
value={form.auth_mode} value={form.auth_mode}
@ -381,20 +398,20 @@ export default function MCPPage() {
</select> </select>
</div> </div>
<div className="space-y-2 sm:col-span-2"> <div className="space-y-2 sm:col-span-2">
<Label>AuthZ </Label> <Label>{t('AuthZ 权限', 'AuthZ permissions')}</Label>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2"> <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-muted-foreground">Audience</span> <span className="text-muted-foreground">Audience</span>
<span className="font-mono text-xs break-all"> <span className="font-mono text-xs break-all">
{showAuthzPreview ? (authAudience || '填写 MCP ID 后自动生成') : '关闭鉴权时无需配置'} {showAuthzPreview ? (authAudience || t('填写 MCP ID 后自动生成', 'Generated after you enter the MCP ID')) : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span> </span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-muted-foreground">Scopes</span> <span className="text-muted-foreground">Scopes</span>
<span className="text-xs break-words"> <span className="text-xs break-words">
{showAuthzPreview {showAuthzPreview
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : '由 AuthZ 当前权限动态决定') ? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : t('由 AuthZ 当前权限动态决定', 'Derived from current AuthZ permissions'))
: '关闭鉴权时无需配置'} : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span> </span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@ -404,7 +421,7 @@ export default function MCPPage() {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="headers"> JSON</Label> <Label htmlFor="headers">{t('请求头 JSON', 'Headers JSON')}</Label>
<Textarea <Textarea
id="headers" id="headers"
rows={8} rows={8}
@ -416,11 +433,11 @@ export default function MCPPage() {
<TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4"> <TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
MCP `npx``uvx` {t('通过命令安装并启动本地 MCP 进程,适合 `npx`、`uvx`、脚本或容器方式。', 'Install and launch a local MCP process with a command, such as `npx`, `uvx`, a script, or a container.')}
</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="command"></Label> <Label htmlFor="command">{t('命令', 'Command')}</Label>
<Input <Input
id="command" id="command"
value={form.command} value={form.command}
@ -430,7 +447,7 @@ export default function MCPPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="args"></Label> <Label htmlFor="args">{t('参数', 'Arguments')}</Label>
<Input <Input
id="args" id="args"
value={form.args} value={form.args}
@ -443,11 +460,11 @@ export default function MCPPage() {
</Tabs> </Tabs>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}> <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t('取消', 'Cancel')}
</Button> </Button>
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />} {submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button> </Button>
</div> </div>
</form> </form>
@ -493,27 +510,27 @@ export default function MCPPage() {
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p> <p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap justify-end"> <div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport)}</Badge> <Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}> <Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status)} {serverStatusLabel(server.status, locale)}
</Badge> </Badge>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-3 text-sm"> <CardContent className="pt-0 space-y-3 text-sm">
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>} {server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
{server.command && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>} {server.command && <div><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.auth_mode}</span></div>} {server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && ( {(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div> <div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)} )}
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>} {(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && ( {server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground"> AuthZ </span></div> <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)} )}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{server.tool_count || 0} </span> <span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span>
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</span> <span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>} {server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div> </div>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
@ -521,21 +538,21 @@ export default function MCPPage() {
event.stopPropagation(); event.stopPropagation();
openEdit(server); openEdit(server);
}}> }}>
{t('编辑', 'Edit')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={(event) => { <Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
void handleTest(server.id); void handleTest(server.id);
}} disabled={testingId === server.id}> }} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />} {testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
{t('测试', 'Test')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={(event) => { <Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
void handleDelete(server.id); void handleDelete(server.id);
}}> }}>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -544,7 +561,7 @@ export default function MCPPage() {
{servers.length === 0 && ( {servers.length === 0 && (
<Card> <Card>
<CardContent className="py-12 text-center text-muted-foreground"> <CardContent className="py-12 text-center text-muted-foreground">
MCP {t('暂无 MCP 服务。', 'There are no MCP servers yet.')}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -554,17 +571,17 @@ export default function MCPPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" /> <Wrench className="w-4 h-4" />
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'} {selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!selectedServer && ( {!selectedServer && (
<div className="py-10 text-sm text-muted-foreground text-center"> <div className="py-10 text-sm text-muted-foreground text-center">
MCP {t('点击左侧 MCP 服务后,这里才会显示对应的已发现工具。', 'Select an MCP server on the left to show its discovered tools here.')}
</div> </div>
)} )}
{selectedServer && !selectedToolGroup && ( {selectedServer && !selectedToolGroup && (
<div className="text-sm text-muted-foreground"> MCP </div> <div className="text-sm text-muted-foreground">{t('这个 MCP 暂时还没有发现任何工具。', 'No tools have been discovered for this MCP yet.')}</div>
)} )}
{selectedToolGroup && ( {selectedToolGroup && (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -34,8 +34,84 @@ import {
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office'; import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
import { appEventKindLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
function traceMetadataLabels(locale: 'zh-CN' | 'en-US'): Record<string, string> {
return {
stage_label: pickAppText(locale, '阶段', 'Stage'),
source: pickAppText(locale, '来源', 'Source'),
phase: 'Phase',
step: 'Step',
selection_mode: pickAppText(locale, '选人方式', 'Selection mode'),
selected_mode: pickAppText(locale, '选中模式', 'Selected mode'),
execution_mode: pickAppText(locale, '执行模式', 'Execution mode'),
selected_targets: pickAppText(locale, '成员', 'Members'),
selected_count: pickAppText(locale, '成员数', 'Member count'),
requested_targets: pickAppText(locale, '请求成员', 'Requested targets'),
planned_targets: pickAppText(locale, '计划成员', 'Planned targets'),
matched_procedure_id: pickAppText(locale, '命中 Procedure', 'Matched procedure'),
candidate_procedure_id: pickAppText(locale, '候选 Procedure', 'Candidate procedure'),
announcement_path: pickAppText(locale, '回流路径', 'Announcement path'),
announcement_sender_id: pickAppText(locale, '回流 Sender', 'Announcement sender'),
announcement_category: pickAppText(locale, '回流类别', 'Announcement category'),
external_fallback_reason: pickAppText(locale, '外部回退原因', 'External fallback reason'),
failure_type: pickAppText(locale, '失败分类', 'Failure type'),
failure_reason: pickAppText(locale, '失败原因', 'Failure reason'),
error: pickAppText(locale, '错误', 'Error'),
origin_channel: pickAppText(locale, '来源 Channel', 'Origin channel'),
origin_chat_id: pickAppText(locale, '来源 Chat', 'Origin chat'),
};
}
function formatTraceValue(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
const parts = value
.map((item) => formatTraceValue(item))
.filter((item): item is string => Boolean(item));
return parts.length > 0 ? parts.join(', ') : null;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function traceMetadataEntries(
metadata: Record<string, unknown> | null | undefined,
labels: Record<string, string>
): Array<{ key: string; label: string; value: string }> {
if (!metadata) return [];
const entries: Array<{ key: string; label: string; value: string }> = [];
const used = new Set<string>();
for (const [key, label] of Object.entries(labels)) {
const value = formatTraceValue(metadata[key]);
if (!value) continue;
used.add(key);
entries.push({ key, label, value });
}
for (const [key, rawValue] of Object.entries(metadata)) {
if (used.has(key)) continue;
const value = formatTraceValue(rawValue);
if (!value) continue;
entries.push({ key, label: key, value });
}
return entries;
}
function PixelPanel({ function PixelPanel({
title, title,
subtitle, subtitle,
@ -87,6 +163,7 @@ function BoardPanel({
} }
export default function OfficeDetailPage() { export default function OfficeDetailPage() {
const { locale } = useAppI18n();
const params = useParams<{ taskId: string }>(); const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? ''); const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
@ -96,9 +173,10 @@ export default function OfficeDetailPage() {
const processArtifacts = useChatStore((state) => state.processArtifacts); const processArtifacts = useChatStore((state) => state.processArtifacts);
const office = React.useMemo( const office = React.useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }), () => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
[processArtifacts, processEvents, processRuns, sessions, taskId] [locale, processArtifacts, processEvents, processRuns, sessions, taskId]
); );
const metadataLabels = React.useMemo(() => traceMetadataLabels(locale), [locale]);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null); const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const [detailOpen, setDetailOpen] = React.useState(false); const [detailOpen, setDetailOpen] = React.useState(false);
@ -112,12 +190,20 @@ export default function OfficeDetailPage() {
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null, () => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
[office?.tasks, selectedRunId] [office?.tasks, selectedRunId]
); );
const selectedRun = React.useMemo(
() => processRuns.find((run) => run.run_id === selectedTask?.runId) ?? null,
[processRuns, selectedTask?.runId]
);
const selectedRunMetadata = React.useMemo(
() => traceMetadataEntries(selectedRun?.metadata, metadataLabels),
[metadataLabels, selectedRun?.metadata]
);
const selectedEvents = React.useMemo( const selectedEvents = React.useMemo(
() => processEvents () => processEvents
.filter((event) => event.run_id === selectedTask?.runId) .filter((event) => event.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8), .slice(0, 16),
[processEvents, selectedTask?.runId] [processEvents, selectedTask?.runId]
); );
@ -139,14 +225,18 @@ export default function OfficeDetailPage() {
<Button asChild variant="outline" className="w-fit"> <Button asChild variant="outline" className="w-fit">
<Link href="/office"> <Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Office {pickAppText(locale, '返回 Office 列表', 'Back to office list')}
</Link> </Link>
</Button> </Button>
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="py-16 text-center"> <CardContent className="py-16 text-center">
<h1 className="text-2xl font-semibold"></h1> <h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
store task Office {pickAppText(
locale,
'当前 store 中没有这个 task 的运行数据。先从对话页发起任务,或者回到 Office 列表查看当前可用任务。',
'The current store does not contain runtime data for this task yet. Start it from chat first, or return to the office list to inspect available tasks.'
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -164,13 +254,13 @@ export default function OfficeDetailPage() {
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href="/office"> <Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Office {pickAppText(locale, '返回 Office', 'Back to office')}
</Link> </Link>
</Button> </Button>
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link href="/"> <Link href="/">
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '回到对话', 'Back to chat')}
</Link> </Link>
</Button> </Button>
</div> </div>
@ -186,18 +276,18 @@ export default function OfficeDetailPage() {
<OfficeStatusBadge status={office.status} className="bg-black/20" /> <OfficeStatusBadge status={office.status} className="bg-black/20" />
</div> </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"> <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>{pickAppText(locale, '负责人', 'Lead')}: {office.rootActorName}</span>
<span>Session: {office.sourceSessionLabel}</span> <span>{pickAppText(locale, '会话', 'Session')}: {office.sourceSessionLabel}</span>
<span>Started: {formatOfficeTime(office.createdAt)}</span> <span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(office.createdAt, locale)}</span>
<span>Duration: {formatOfficeDuration(office.durationMs)}</span> <span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(office.durationMs, locale)}</span>
</div> </div>
</div> </div>
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]"> <div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} /> <MetricTile label={pickAppText(locale, '运行实例', 'Runs')} value={String(office.stats.totalRuns)} />
<MetricTile label="参与成员" value={String(office.stats.memberCount)} /> <MetricTile label={pickAppText(locale, '参与成员', 'Members')} value={String(office.stats.memberCount)} />
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} /> <MetricTile label={pickAppText(locale, '产物数量', 'Artifacts')} value={String(office.stats.artifactCount)} />
<MetricTile label="告警数量" value={String(office.alerts.length)} /> <MetricTile label={pickAppText(locale, '告警数量', 'Alerts')} value={String(office.alerts.length)} />
</div> </div>
</div> </div>
</div> </div>
@ -213,12 +303,12 @@ export default function OfficeDetailPage() {
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]"> <div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
<PixelPanel <PixelPanel
title="昨日小记" title={pickAppText(locale, '昨日小记', 'Yesterday notes')}
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。" subtitle={pickAppText(locale, '用任务摘要、告警和最近更新来替代原版 memo 区。', 'Use task summaries, alerts, and recent updates instead of the original memo area.')}
> >
<div className="space-y-3 text-sm leading-6 text-slate-300"> <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"> <div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
{selectedTask?.summary || '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。'} {selectedTask?.summary || pickAppText(locale, '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。', 'The selected task has no summary yet. Pick a specific run from the board on the right to inspect the floor.')}
</div> </div>
{office.alerts.slice(0, 2).map((alert) => ( {office.alerts.slice(0, 2).map((alert) => (
<button <button
@ -236,13 +326,13 @@ export default function OfficeDetailPage() {
</PixelPanel> </PixelPanel>
<PixelPanel <PixelPanel
title="任务控制台" title={pickAppText(locale, '任务控制台', 'Task console')}
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。" subtitle={pickAppText(locale, '保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。', 'Keep the original center console position, but back it with real task runtime data.')}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} /> <MiniMetric label={pickAppText(locale, '当前阶段', 'Current stage')} value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} /> <MiniMetric label={pickAppText(locale, '活跃实例', 'Active runs')} value={String(office.stats.activeRuns)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -260,10 +350,10 @@ export default function OfficeDetailPage() {
{selectedTask ? ( {selectedTask ? (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3"> <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="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{pickAppText(locale, '当前聚焦', 'Current focus')}</div>
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div> <div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
<div className="mt-1 text-xs text-slate-400"> <div className="mt-1 text-xs text-slate-400">
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'} {selectedTask.actorName} · {selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}
</div> </div>
</div> </div>
) : null} ) : null}
@ -273,7 +363,7 @@ export default function OfficeDetailPage() {
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]" className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
> >
{pickAppText(locale, '打开详情', 'Open details')}
<PanelRightOpen className="ml-2 h-4 w-4" /> <PanelRightOpen className="ml-2 h-4 w-4" />
</Button> </Button>
<Button <Button
@ -282,7 +372,7 @@ export default function OfficeDetailPage() {
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]" className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
> >
<Link href="/"> <Link href="/">
{pickAppText(locale, '回到对话', 'Back to chat')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
@ -290,15 +380,15 @@ export default function OfficeDetailPage() {
{isOfficeTaskTerminal(office.status) ? ( {isOfficeTaskTerminal(office.status) ? (
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200"> <div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
{pickAppText(locale, '任务已结束,办公室已解散,但现场记录仍可回看。', 'The task has ended and the office has dissolved, but the floor record is still available for review.')}
</div> </div>
) : null} ) : null}
</div> </div>
</PixelPanel> </PixelPanel>
<PixelPanel <PixelPanel
title="办公人员名单" title={pickAppText(locale, '办公人员名单', 'Roster')}
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。" subtitle={pickAppText(locale, '原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。', 'Replacement for the original visitor area, showing the agents currently participating in this task.')}
> >
<div className="space-y-2"> <div className="space-y-2">
{office.members.map((member) => ( {office.members.map((member) => (
@ -322,8 +412,8 @@ export default function OfficeDetailPage() {
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]"> <div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
<BoardPanel <BoardPanel
icon={ListTree} icon={ListTree}
title="任务看板" title={pickAppText(locale, '任务看板', 'Task board')}
description="当前 task 下所有 run 的结构化列表。" description={pickAppText(locale, '当前 task 下所有 run 的结构化列表。', 'Structured list of all runs under this task.')}
> >
<div className="space-y-3"> <div className="space-y-3">
{office.tasks.map((task) => ( {office.tasks.map((task) => (
@ -349,8 +439,8 @@ export default function OfficeDetailPage() {
</div> </div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400"> <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
<span>{task.actorName}</span> <span>{task.actorName}</span>
<span>{formatOfficeTime(task.updatedAt)}</span> <span>{formatOfficeTime(task.updatedAt, locale)}</span>
<span>{task.artifactCount} </span> <span>{pickAppText(locale, `${task.artifactCount} 个产物`, `${task.artifactCount} artifacts`)}</span>
</div> </div>
</div> </div>
<OfficeStatusBadge status={task.status} /> <OfficeStatusBadge status={task.status} />
@ -363,13 +453,13 @@ export default function OfficeDetailPage() {
<div className="space-y-5"> <div className="space-y-5">
<BoardPanel <BoardPanel
icon={Boxes} icon={Boxes}
title="分工关系" title={pickAppText(locale, '分工关系', 'Assignments')}
description="主 Agent 到子 Agent 的委派关系。" description={pickAppText(locale, '主 Agent 到子 Agent 的委派关系。', 'Delegation links from the lead agent to sub-agents.')}
> >
<div className="space-y-2"> <div className="space-y-2">
{office.assignments.length === 0 ? ( {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 className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
{pickAppText(locale, '当前没有可见的子任务分工。', 'No visible subtask assignments yet.')}
</div> </div>
) : ( ) : (
office.assignments.map((assignment) => ( office.assignments.map((assignment) => (
@ -389,13 +479,13 @@ export default function OfficeDetailPage() {
<BoardPanel <BoardPanel
icon={Siren} icon={Siren}
title="现场告警" title={pickAppText(locale, '现场告警', 'Live alerts')}
description="优先展示失败、阻塞和较高风险的任务信号。" description={pickAppText(locale, '优先展示失败、阻塞和较高风险的任务信号。', 'Prioritize failed, blocked, and higher-risk task signals.')}
> >
<div className="space-y-2"> <div className="space-y-2">
{office.alerts.length === 0 ? ( {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 className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
{pickAppText(locale, '当前没有高优先级告警。', 'There are no high-priority alerts right now.')}
</div> </div>
) : ( ) : (
office.alerts.map((alert) => ( office.alerts.map((alert) => (
@ -420,17 +510,17 @@ export default function OfficeDetailPage() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}> <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl"> <SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
<SheetHeader className="pr-8"> <SheetHeader className="pr-8">
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle> <SheetTitle>{selectedTask?.title ?? pickAppText(locale, '任务详情', 'Task details')}</SheetTitle>
<SheetDescription> <SheetDescription>
{selectedTask {selectedTask
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}` ? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}`
: '当前没有选中的任务实例。'} : pickAppText(locale, '当前没有选中的任务实例。', 'No task run is currently selected.')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
{!selectedTask ? ( {!selectedTask ? (
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground"> <div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
{pickAppText(locale, '当前没有可展示的任务详情。', 'There are no task details to display right now.')}
</div> </div>
) : ( ) : (
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3"> <ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
@ -445,18 +535,31 @@ export default function OfficeDetailPage() {
</div> </div>
<div className="mt-3 grid gap-2 text-sm"> <div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{pickAppText(locale, '开始时间', 'Started')}</span>
<span>{formatOfficeTime(selectedTask.startedAt)}</span> <span>{formatOfficeTime(selectedTask.startedAt, locale)}</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{pickAppText(locale, '最近更新', 'Last update')}</span>
<span>{formatOfficeTime(selectedTask.updatedAt)}</span> <span>{formatOfficeTime(selectedTask.updatedAt, locale)}</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">{pickAppText(locale, '阶段', 'Stage')}</span>
<span>{selectedTask.stageLabel ?? '-'}</span> <span>{selectedTask.stageLabel ?? '-'}</span>
</div> </div>
</div> </div>
{selectedRunMetadata.length > 0 ? (
<div className="mt-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '链路上下文', 'Trace context')}</div>
<div className="mt-2 space-y-1.5">
{selectedRunMetadata.map((item) => (
<div key={item.key} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
<span className="text-muted-foreground">{item.label}</span>
<span className="break-words font-mono text-foreground/90">{item.value}</span>
</div>
))}
</div>
</div>
) : null}
{selectedTask.summary ? ( {selectedTask.summary ? (
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground"> <div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
{selectedTask.summary} {selectedTask.summary}
@ -466,16 +569,16 @@ export default function OfficeDetailPage() {
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]"> <div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-xl border border-border/60"> <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="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '产物', 'Artifacts')}</div>
<div className="space-y-2 p-4"> <div className="space-y-2 p-4">
{selectedArtifacts.length === 0 ? ( {selectedArtifacts.length === 0 ? (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有产物。', 'There are no artifacts for this task.')}</div>
) : ( ) : (
selectedArtifacts.map((artifact) => ( selectedArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3"> <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="font-medium">{artifact.title}</div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)} {artifact.artifact_type} · {formatOfficeTime(artifact.created_at, locale)}
</div> </div>
</div> </div>
)) ))
@ -484,22 +587,40 @@ export default function OfficeDetailPage() {
</div> </div>
<div className="rounded-xl border border-border/60"> <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="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '最近事件', 'Recent events')}</div>
<div className="space-y-2 p-4"> <div className="space-y-2 p-4">
{selectedEvents.length === 0 ? ( {selectedEvents.length === 0 ? (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有事件。', 'There are no events for this task.')}</div>
) : ( ) : (
selectedEvents.map((event) => ( selectedEvents.map((event) => {
const metadataEntries = traceMetadataEntries(event.metadata, metadataLabels);
return (
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3"> <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="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div> <div className="text-xs uppercase tracking-wide text-muted-foreground">{appEventKindLabel(event.kind, locale)}</div>
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div> <div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at, locale)}</div>
</div> </div>
{event.status ? (
<div className="mt-2 text-xs text-muted-foreground">{pickAppText(locale, '状态', 'Status')}: {event.status}</div>
) : null}
<div className="mt-2 text-sm text-foreground/90"> <div className="mt-2 text-sm text-foreground/90">
{event.text || '结构化更新'} {event.text || pickAppText(locale, '结构化更新', 'Structured update')}
</div> </div>
{metadataEntries.length > 0 ? (
<div className="mt-3 rounded-md bg-muted/20 px-3 py-2">
<div className="mb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '事件上下文', 'Event context')}</div>
<div className="space-y-1.5">
{metadataEntries.map((item) => (
<div key={`${event.event_id}:${item.key}`} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
<span className="text-muted-foreground">{item.label}</span>
<span className="break-words font-mono text-foreground/90">{item.value}</span>
</div>
))}
</div>
</div>
) : null}
</div> </div>
)) )})
)} )}
</div> </div>
</div> </div>

View File

@ -17,6 +17,9 @@ import { TaskManagementTabs } from '@/components/task-management/TaskManagementT
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office'; import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { appConnectionStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
function TaskCard({ function TaskCard({
@ -33,6 +36,7 @@ function TaskCard({
currentStageLabel, currentStageLabel,
progressLabel, progressLabel,
progressValue, progressValue,
locale,
}: { }: {
taskId: string; taskId: string;
title: string; title: string;
@ -47,6 +51,7 @@ function TaskCard({
currentStageLabel: string | null; currentStageLabel: string | null;
progressLabel: string; progressLabel: string;
progressValue: number; progressValue: number;
locale: 'zh-CN' | 'en-US';
}) { }) {
return ( return (
<Card className="border-border/80 transition-colors hover:border-primary/30"> <Card className="border-border/80 transition-colors hover:border-primary/30">
@ -55,9 +60,9 @@ function TaskCard({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<CardTitle className="truncate text-lg">{title}</CardTitle> <CardTitle className="truncate text-lg">{title}</CardTitle>
<CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> <CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>: {sessionLabel}</span> <span>{pickAppText(locale, '会话', 'Session')}: {sessionLabel}</span>
<span> Agent: {rootActorName}</span> <span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {rootActorName}</span>
<span> {formatOfficeTime(updatedAt)}</span> <span>{pickAppText(locale, '更新于', 'Updated')} {formatOfficeTime(updatedAt, locale)}</span>
</CardDescription> </CardDescription>
</div> </div>
<OfficeStatusBadge status={status} /> <OfficeStatusBadge status={status} />
@ -65,10 +70,10 @@ function TaskCard({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-4">
<Metric icon={Users} label="成员" value={String(memberCount)} /> <Metric icon={Users} label={pickAppText(locale, '成员', 'Members')} value={String(memberCount)} />
<Metric icon={Activity} label="活跃" value={String(activeRuns)} /> <Metric icon={Activity} label={pickAppText(locale, '活跃', 'Active')} value={String(activeRuns)} />
<Metric icon={FolderKanban} label="产物" value={String(artifactCount)} /> <Metric icon={FolderKanban} label={pickAppText(locale, '产物', 'Artifacts')} value={String(artifactCount)} />
<Metric icon={Sparkles} label="异常" value={String(errorCount)} /> <Metric icon={Sparkles} label={pickAppText(locale, '异常', 'Alerts')} value={String(errorCount)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -87,7 +92,7 @@ function TaskCard({
<div className="flex justify-end"> <div className="flex justify-end">
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(taskId)}`}> <Link href={`/office/${encodeURIComponent(taskId)}`}>
{pickAppText(locale, '进入办公室', 'Open office')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
@ -118,6 +123,7 @@ function Metric({
} }
export default function OfficeListPage() { export default function OfficeListPage() {
const { locale } = useAppI18n();
const sessionId = useChatStore((state) => state.sessionId); const sessionId = useChatStore((state) => state.sessionId);
const sessions = useChatStore((state) => state.sessions); const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns); const processRuns = useChatStore((state) => state.processRuns);
@ -132,8 +138,8 @@ export default function OfficeListPage() {
processRuns, processRuns,
processEvents, processEvents,
processArtifacts, processArtifacts,
}), }, locale),
[processArtifacts, processEvents, processRuns, sessionId, sessions] [locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
); );
const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status)); const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status));
@ -147,18 +153,22 @@ export default function OfficeListPage() {
<div> <div>
<h1 className="text-3xl font-semibold tracking-tight">Office</h1> <h1 className="text-3xl font-semibold tracking-tight">Office</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground"> <p className="mt-2 max-w-3xl text-sm text-muted-foreground">
Agent Agent {pickAppText(
locale,
'基于当前会话的真实运行数据,展示主 Agent 与子 Agent 的任务现场。任务结束后会从活跃现场移除,但保留回看入口。',
'Show the live task floor for the lead agent and its sub-agents using real runtime data from the current session. Finished tasks leave the active floor but remain available for review.'
)}
</p> </p>
</div> </div>
<Card className="min-w-[280px] border-border/70"> <Card className="min-w-[280px] border-border/70">
<CardContent className="flex items-center justify-between gap-4 p-4"> <CardContent className="flex items-center justify-between gap-4 p-4">
<div> <div>
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{pickAppText(locale, '当前会话', 'Current session')}</div>
<div className="mt-1 font-medium">{sessionId}</div> <div className="mt-1 font-medium">{sessionId}</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{pickAppText(locale, '连接状态', 'Connection')}</div>
<div className="mt-1 font-medium">{wsStatus === 'connected' ? '已连接' : wsStatus}</div> <div className="mt-1 font-medium">{appConnectionStatusLabel(wsStatus, wsStatus === 'connected' ? true : null, locale)}</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -167,7 +177,7 @@ export default function OfficeListPage() {
{wsStatus === 'connecting' && tasks.length === 0 ? ( {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"> <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" /> <Loader2 className="h-4 w-4 animate-spin" />
... {pickAppText(locale, '正在等待运行时数据...', 'Waiting for runtime data...')}
</div> </div>
) : null} ) : null}
@ -175,12 +185,16 @@ export default function OfficeListPage() {
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center"> <CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Clock3 className="h-10 w-10 text-muted-foreground/50" /> <Clock3 className="h-10 w-10 text-muted-foreground/50" />
<h2 className="mt-4 text-xl font-semibold"></h2> <h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '当前没有可展示的任务现场', 'No task floor is available yet')}</h2>
<p className="mt-2 max-w-xl text-sm text-muted-foreground"> <p className="mt-2 max-w-xl text-sm text-muted-foreground">
Agent office {pickAppText(
locale,
'先回到对话页发起一次主 Agent 任务。开始执行后,这里会出现活跃的 office 卡片。',
'Start a lead-agent task from the chat page first. Once it begins running, active office cards will appear here.'
)}
</p> </p>
<Button asChild className="mt-6"> <Button asChild className="mt-6">
<Link href="/"></Link> <Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -189,15 +203,15 @@ export default function OfficeListPage() {
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold"> Office</h2> <h2 className="text-xl font-semibold">{pickAppText(locale, '活跃 Office', 'Active office')}</h2>
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{pickAppText(locale, '正在运行中的任务现场会优先显示。', 'Running task floors are shown first.')}</p>
</div> </div>
<div className="text-sm text-muted-foreground">{activeTasks.length} </div> <div className="text-sm text-muted-foreground">{pickAppText(locale, `${activeTasks.length} 个任务`, `${activeTasks.length} tasks`)}</div>
</div> </div>
{activeTasks.length === 0 ? ( {activeTasks.length === 0 ? (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground"> <CardContent className="py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '当前没有活跃任务,下面可以查看最近结束的任务。', 'There are no active tasks right now. Recent finished tasks are listed below.')}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -218,6 +232,7 @@ export default function OfficeListPage() {
currentStageLabel={task.currentStageLabel} currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label} progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)} progressValue={progressPercent(task.progress.value, task.progress.max)}
locale={locale}
/> />
))} ))}
</div> </div>
@ -227,15 +242,15 @@ export default function OfficeListPage() {
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold">{pickAppText(locale, '最近结束', 'Recently finished')}</h2>
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{pickAppText(locale, '已完成、失败或取消的任务仍保留回看入口。', 'Completed, failed, or cancelled tasks remain available for review.')}</p>
</div> </div>
<div className="text-sm text-muted-foreground">{recentTasks.length} </div> <div className="text-sm text-muted-foreground">{pickAppText(locale, `${recentTasks.length} 个任务`, `${recentTasks.length} tasks`)}</div>
</div> </div>
{recentTasks.length === 0 ? ( {recentTasks.length === 0 ? (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground"> <CardContent className="py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '还没有历史任务。', 'There is no task history yet.')}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -256,6 +271,7 @@ export default function OfficeListPage() {
currentStageLabel={task.currentStageLabel} currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label} progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)} progressValue={progressPercent(task.progress.value, task.progress.max)}
locale={locale}
/> />
))} ))}
</div> </div>

View File

@ -54,6 +54,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type OutlookFormState = OutlookConnectionPayload; type OutlookFormState = OutlookConnectionPayload;
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings'; type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
@ -88,11 +91,11 @@ function toFormState(status: OutlookStatus | null): OutlookFormState {
}; };
} }
function formatDateTime(value?: string | null): string { function formatDateTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@ -115,19 +118,19 @@ function formatDateKey(value?: string | null): string | null {
return toLocalDateKey(date); return toLocalDateKey(date);
} }
function formatDayLabel(value: Date): string { function formatDayLabel(value: Date, locale: AppLocale = 'zh-CN'): string {
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale, {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
weekday: 'short', weekday: 'short',
}).format(value); }).format(value);
} }
function formatTime(value?: string | null): string { function formatTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale, {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}).format(date); }).format(date);
@ -241,9 +244,9 @@ function sanitizeEmailHtml(html: string): string {
return documentRef.body.innerHTML; return documentRef.body.innerHTML;
} }
function buildEmailPreviewDocument(html: string): string { function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="zh-CN"> <html lang="${locale}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -334,6 +337,8 @@ function renderPlainText(content: string): React.ReactNode[] {
} }
export default function OutlookPage() { export default function OutlookPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [status, setStatus] = useState<OutlookStatus | null>(null); const [status, setStatus] = useState<OutlookStatus | null>(null);
const [form, setForm] = useState<OutlookFormState>(EMPTY_FORM); const [form, setForm] = useState<OutlookFormState>(EMPTY_FORM);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
@ -382,11 +387,11 @@ export default function OutlookPage() {
if (!preserveExisting) { if (!preserveExisting) {
setOverview(null); setOverview(null);
} }
setError(err.message || '加载 Outlook 概览失败'); setError(err.message || t('加载 Outlook 概览失败', 'Failed to load the Outlook overview'));
} finally { } finally {
setOverviewLoading(false); setOverviewLoading(false);
} }
}, []); }, [t]);
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => { const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
setMailboxLoading((current) => ({ ...current, [view]: true })); setMailboxLoading((current) => ({ ...current, [view]: true }));
@ -402,11 +407,17 @@ export default function OutlookPage() {
} }
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`); setError(
err.message
|| t(
`加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`,
`Failed to load the ${view === 'inbox' ? 'inbox' : 'sent mailbox'}`
)
);
} finally { } finally {
setMailboxLoading((current) => ({ ...current, [view]: false })); setMailboxLoading((current) => ({ ...current, [view]: false }));
} }
}, []); }, [t]);
const loadCalendarPage = useCallback(async (anchorKey: string) => { const loadCalendarPage = useCallback(async (anchorKey: string) => {
setCalendarLoading(true); setCalendarLoading(true);
@ -420,11 +431,11 @@ export default function OutlookPage() {
setCalendarPage(nextPage); setCalendarPage(nextPage);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载日程失败'); setError(err.message || t('加载日程失败', 'Failed to load calendar events'));
} finally { } finally {
setCalendarLoading(false); setCalendarLoading(false);
} }
}, []); }, [t]);
const loadStatus = useCallback(async ( const loadStatus = useCallback(async (
background = false, background = false,
@ -452,7 +463,7 @@ export default function OutlookPage() {
setOverviewLoading(false); setOverviewLoading(false);
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载 Outlook 集成状态失败'); setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status'));
setOverviewLoading(false); setOverviewLoading(false);
} finally { } finally {
if (background) { if (background) {
@ -461,7 +472,7 @@ export default function OutlookPage() {
setStatusLoading(false); setStatusLoading(false);
} }
} }
}, [applyStatus, loadOverview]); }, [applyStatus, loadOverview, t]);
useEffect(() => { useEffect(() => {
void loadStatus(); void loadStatus();
@ -483,7 +494,7 @@ export default function OutlookPage() {
}) })
.catch((err: any) => { .catch((err: any) => {
if (!cancelled) { if (!cancelled) {
setError(err.message || '加载邮件详情失败'); setError(err.message || t('加载邮件详情失败', 'Failed to load message details'));
} }
}) })
.finally(() => { .finally(() => {
@ -495,7 +506,7 @@ export default function OutlookPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedMessageRef]); }, [selectedMessageRef, t]);
const canTest = useMemo( const canTest = useMemo(
() => Boolean( () => Boolean(
@ -519,8 +530,8 @@ export default function OutlookPage() {
return [ return [
{ {
id: 'settings' as const, id: 'settings' as const,
label: '设置', label: t('设置', 'Settings'),
hint: '配置 Outlook 连接', hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
icon: Settings2, icon: Settings2,
count: null, count: null,
}, },
@ -530,34 +541,34 @@ export default function OutlookPage() {
return [ return [
{ {
id: 'inbox' as const, id: 'inbox' as const,
label: '收件箱', label: t('收件箱', 'Inbox'),
hint: '最近接收邮件', hint: t('最近接收邮件', 'Recently received mail'),
icon: Inbox, icon: Inbox,
count: null, count: null,
}, },
{ {
id: 'sent' as const, id: 'sent' as const,
label: '发件箱', label: t('发件箱', 'Sent'),
hint: '最近发送记录', hint: t('最近发送记录', 'Recently sent messages'),
icon: Send, icon: Send,
count: null, count: null,
}, },
{ {
id: 'calendar' as const, id: 'calendar' as const,
label: '日程', label: t('日程', 'Calendar'),
hint: '未来 7 天', hint: t('未来 7 天', 'Next 7 days'),
icon: CalendarDays, icon: CalendarDays,
count: overviewPending ? null : eventCount, count: overviewPending ? null : eventCount,
}, },
{ {
id: 'settings' as const, id: 'settings' as const,
label: '设置', label: t('设置', 'Settings'),
hint: '连接与状态', hint: t('连接与状态', 'Connection and status'),
icon: Settings2, icon: Settings2,
count: null, count: null,
}, },
]; ];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount]); }, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
useEffect(() => { useEffect(() => {
if (!availableViews.some((view) => view.id === activeView)) { if (!availableViews.some((view) => view.id === activeView)) {
@ -604,7 +615,7 @@ export default function OutlookPage() {
const result = await testOutlookConnection(form); const result = await testOutlookConnection(form);
setTestResult(result); setTestResult(result);
} catch (err: any) { } catch (err: any) {
setError(err.message || '测试连接失败'); setError(err.message || t('测试连接失败', 'Failed to test the connection'));
setTestResult(null); setTestResult(null);
} finally { } finally {
setTesting(false); setTesting(false);
@ -626,7 +637,7 @@ export default function OutlookPage() {
await loadStatus(true, { forceFormSync: true }); await loadStatus(true, { forceFormSync: true });
setActiveView('inbox'); setActiveView('inbox');
} catch (err: any) { } catch (err: any) {
setError(err.message || '保存 Outlook 配置失败'); setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -649,7 +660,7 @@ export default function OutlookPage() {
setFormDirty(false); setFormDirty(false);
await loadStatus(true, { forceFormSync: true }); await loadStatus(true, { forceFormSync: true });
} catch (err: any) { } catch (err: any) {
setError(err.message || '断开 Outlook 连接失败'); setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook'));
} finally { } finally {
setDisconnecting(false); setDisconnecting(false);
} }
@ -688,16 +699,16 @@ export default function OutlookPage() {
) : ( ) : (
<> <>
<Badge variant={statusVariant(isConnected)}> <Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'} {isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge> </Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}> <Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'} {status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge> </Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge> <Badge variant="secondary">{status?.provider || 'ews'}</Badge>
<span className="text-muted-foreground"> {overview?.mailbox || status?.saved?.email || '-'}</span> <span className="text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground"> {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span> <span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)} {t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span> </span>
</> </>
)} )}
@ -706,14 +717,14 @@ export default function OutlookPage() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{isConfigured ? ( {isConfigured ? (
<> <>
<TopStat label="收件箱" value={String(inboxCount)} loading={overviewPending} /> <TopStat label={t('收件箱', 'Inbox')} value={String(inboxCount)} loading={overviewPending} />
<TopStat label="发件箱" value={String(sentCount)} loading={overviewPending} /> <TopStat label={t('发件箱', 'Sent')} value={String(sentCount)} loading={overviewPending} />
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} /> <TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</> </>
) : null} ) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}> <Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@ -765,7 +776,7 @@ export default function OutlookPage() {
<div className="text-left"> <div className="text-left">
<p className="text-sm font-semibold">{view.label}</p> <p className="text-sm font-semibold">{view.label}</p>
{typeof view.count === 'number' ? ( {typeof view.count === 'number' ? (
<p className="text-xs text-muted-foreground">{view.count} </p> <p className="text-xs text-muted-foreground">{t(`${view.count}`, `${view.count} items`)}</p>
) : null} ) : null}
</div> </div>
</div> </div>
@ -777,12 +788,13 @@ export default function OutlookPage() {
<TabsContent value="inbox" className="mt-0"> <TabsContent value="inbox" className="mt-0">
<MessageCard <MessageCard
title="收件箱" title={t('收件箱', 'Inbox')}
icon={<MailOpen className="h-4 w-4" />} icon={<MailOpen className="h-4 w-4" />}
items={inboxPage?.value || []} items={inboxPage?.value || []}
page={inboxPage?.page || null} page={inboxPage?.page || null}
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)} loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
emptyLabel="还没有读取到收件箱邮件" emptyLabel={t('还没有读取到收件箱邮件', 'No inbox messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)} onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)} onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
refreshing={mailboxLoading.inbox} refreshing={mailboxLoading.inbox}
@ -798,12 +810,13 @@ export default function OutlookPage() {
<TabsContent value="sent" className="mt-0"> <TabsContent value="sent" className="mt-0">
<MessageCard <MessageCard
title="发件箱" title={t('发件箱', 'Sent')}
icon={<Send className="h-4 w-4" />} icon={<Send className="h-4 w-4" />}
items={sentPage?.value || []} items={sentPage?.value || []}
page={sentPage?.page || null} page={sentPage?.page || null}
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)} loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
emptyLabel="还没有读取到已发送邮件" emptyLabel={t('还没有读取到已发送邮件', 'No sent messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)} onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)} onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
refreshing={mailboxLoading.sent} refreshing={mailboxLoading.sent}
@ -822,6 +835,7 @@ export default function OutlookPage() {
items={calendarPage?.value || []} items={calendarPage?.value || []}
startDate={calendarAnchorKey} startDate={calendarAnchorKey}
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)} loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
locale={locale}
onOpen={(item) => setSelectedEvent(item)} onOpen={(item) => setSelectedEvent(item)}
onRefresh={() => void loadCalendarPage(calendarAnchorKey)} onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
refreshing={calendarLoading} refreshing={calendarLoading}
@ -849,33 +863,33 @@ export default function OutlookPage() {
<div className="grid gap-6 xl:grid-cols-[1.08fr,0.92fr]"> <div className="grid gap-6 xl:grid-cols-[1.08fr,0.92fr]">
<Card className="rounded-[28px] shadow-sm"> <Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5"> <CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle> <CardTitle className="text-xl text-foreground">{t('连接设置', 'Connection settings')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5 pt-6"> <CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="邮箱地址" required> <Field label={t('邮箱地址', 'Email address')} required>
<Input <Input
value={form.email} value={form.email}
onChange={(event) => updateField('email', event.target.value)} onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com" placeholder="you@boardware.com"
/> />
</Field> </Field>
<Field label="用户名"> <Field label={t('用户名', 'Username')}>
<Input <Input
value={form.username} value={form.username}
onChange={(event) => updateField('username', event.target.value)} onChange={(event) => updateField('username', event.target.value)}
placeholder="留空时默认取邮箱前缀" placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/> />
</Field> </Field>
<Field label="密码" required> <Field label={t('密码', 'Password')} required>
<Input <Input
type="password" type="password"
value={form.password} value={form.password}
onChange={(event) => updateField('password', event.target.value)} onChange={(event) => updateField('password', event.target.value)}
placeholder="请输入邮箱密码" placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/> />
</Field> </Field>
<Field label="域"> <Field label={t('域', 'Domain')}>
<Input <Input
value={form.domain} value={form.domain}
onChange={(event) => updateField('domain', event.target.value)} onChange={(event) => updateField('domain', event.target.value)}
@ -898,7 +912,7 @@ export default function OutlookPage() {
disabled={form.autodiscover} disabled={form.autodiscover}
/> />
</Field> </Field>
<Field label="时区"> <Field label={t('时区', 'Timezone')}>
<Input <Input
value={form.default_timezone} value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)} onChange={(event) => updateField('default_timezone', event.target.value)}
@ -912,7 +926,7 @@ export default function OutlookPage() {
Autodiscover Autodiscover
</Label> </Label>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
使 Exchange EWS URL {t('开启后优先使用 Exchange 自动发现,不再强依赖手填 EWS URL。', 'When enabled, Exchange autodiscover is preferred so the EWS URL does not need to be entered manually.')}
</p> </p>
</div> </div>
<Switch <Switch
@ -927,11 +941,11 @@ export default function OutlookPage() {
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}> <Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />} {testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('测试连接', 'Test connection')}
</Button> </Button>
<Button onClick={handleConnect} disabled={!canTest || saving}> <Button onClick={handleConnect} disabled={!canTest || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />} {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('保存并启用', 'Save and enable')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -939,21 +953,21 @@ export default function OutlookPage() {
disabled={!status?.configured || disconnecting} disabled={!status?.configured || disconnecting}
> >
{disconnecting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unplug className="mr-2 h-4 w-4" />} {disconnecting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unplug className="mr-2 h-4 w-4" />}
{t('断开连接', 'Disconnect')}
</Button> </Button>
</div> </div>
{testResult && ( {testResult && (
<div className="rounded-3xl border bg-muted/30 p-4 text-sm"> <div className="rounded-3xl border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="default"></Badge> <Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</span> <span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">: {testResult.resolved_username}</span> <span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
</div> </div>
<div className="mt-3 grid gap-3 md:grid-cols-3"> <div className="mt-3 grid gap-3 md:grid-cols-3">
<MiniStat label="检测到文件夹" value={String(testResult.sample.folders.length)} /> <MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
<MiniStat label="收件箱样本" value={String(testResult.sample.inbox.length)} /> <MiniStat label={t('收件箱样本', 'Inbox samples')} value={String(testResult.sample.inbox.length)} />
<MiniStat label="日程样本" value={String(testResult.sample.events.length)} /> <MiniStat label={t('日程样本', 'Calendar samples')} value={String(testResult.sample.events.length)} />
</div> </div>
{testWarnings.length > 0 && ( {testWarnings.length > 0 && (
<div className="mt-4 space-y-2 rounded-2xl border border-amber-300 bg-amber-50/80 p-3 text-amber-900"> <div className="mt-4 space-y-2 rounded-2xl border border-amber-300 bg-amber-50/80 p-3 text-amber-900">
@ -972,7 +986,7 @@ export default function OutlookPage() {
<Card className="rounded-[28px] shadow-sm"> <Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5"> <CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle> <CardTitle className="text-xl text-foreground">{t('连接状态', 'Connection status')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-6"> <CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -985,27 +999,27 @@ export default function OutlookPage() {
) : ( ) : (
<> <>
<Badge variant={statusVariant(isConnected)}> <Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'} {isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge> </Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}> <Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'} {status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge> </Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge> <Badge variant="secondary">{status?.provider || 'ews'}</Badge>
</> </>
)} )}
</div> </div>
<InfoRow label="邮箱" value={status?.saved?.email || '-'} loading={statusPending} /> <InfoRow label={t('邮箱', 'Email')} value={status?.saved?.email || '-'} loading={statusPending} />
<InfoRow label="用户名" value={status?.saved?.username || '-'} loading={statusPending} /> <InfoRow label={t('用户名', 'Username')} value={status?.saved?.username || '-'} loading={statusPending} />
<InfoRow label="域" value={status?.saved?.domain || '-'} loading={statusPending} /> <InfoRow label={t('域', 'Domain')} value={status?.saved?.domain || '-'} loading={statusPending} />
<InfoRow label="EWS URL" value={status?.saved?.service_endpoint || '-'} loading={statusPending} /> <InfoRow label="EWS URL" value={status?.saved?.service_endpoint || '-'} loading={statusPending} />
<InfoRow label="Server Host" value={status?.saved?.server || '-'} loading={statusPending} /> <InfoRow label="Server Host" value={status?.saved?.server || '-'} loading={statusPending} />
<InfoRow label="时区" value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} /> <InfoRow label={t('时区', 'Timezone')} value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
<InfoRow label="最近验证" value={formatDateTime(status?.meta?.last_verified_at as string | undefined)} loading={statusPending} /> <InfoRow label={t('最近验证', 'Last verified')} value={formatDateTime(status?.meta?.last_verified_at as string | undefined, locale)} loading={statusPending} />
<InfoRow label="最近接入" value={formatDateTime(status?.meta?.last_connected_at as string | undefined)} loading={statusPending} /> <InfoRow label={t('最近接入', 'Last connected')} value={formatDateTime(status?.meta?.last_connected_at as string | undefined, locale)} loading={statusPending} />
<InfoRow <InfoRow
label="最近刷新" label={t('最近刷新', 'Last refreshed')}
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)} value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
loading={statusPending || overviewPending} loading={statusPending || overviewPending}
/> />
@ -1017,12 +1031,15 @@ export default function OutlookPage() {
<div className="rounded-3xl border bg-muted/30 p-4"> <div className="rounded-3xl border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('当前存储位置', 'Current storage mode')}
</p> </p>
<p className="mt-2 text-sm font-medium text-foreground"> <p className="mt-2 text-sm font-medium text-foreground">
{status?.storage_mode === 'authz' {status?.storage_mode === 'authz'
? '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。' ? t(
: <> workspace Outlook workspace <code>state/bw_outlook_mcp</code></>} '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。',
'AuthZ mode is active. Outlook credentials are stored in the AuthZ service and read by the external Outlook MCP using the backend identity.'
)
: <>{t('当前为 workspace 模式。Outlook 状态文件会写入当前 workspace 的', 'Workspace mode is active. Outlook state files are written to')} <code>state/bw_outlook_mcp</code>.</>}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -1034,9 +1051,9 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}> <Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl"> <DialogContent className="sm:max-w-5xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{selectedMessage?.subject || '邮件详情'}</DialogTitle> <DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime) : '正在加载'} {selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{messageLoading ? ( {messageLoading ? (
@ -1046,32 +1063,33 @@ export default function OutlookPage() {
) : selectedMessage ? ( ) : selectedMessage ? (
<div className="grid gap-4 lg:grid-cols-[280px,1fr]"> <div className="grid gap-4 lg:grid-cols-[280px,1fr]">
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm"> <div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
<InfoRow label="发件人" value={mailboxLabel(selectedMessage.from)} /> <InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow <InfoRow
label="收件人" label={t('收件人', 'To')}
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'} value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/> />
<InfoRow <InfoRow
label="抄送" label={t('抄送', 'Cc')}
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'} value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/> />
<InfoRow label="接收时间" value={formatDateTime(selectedMessage.receivedDateTime)} /> <InfoRow label={t('接收时间', 'Received at')} value={formatDateTime(selectedMessage.receivedDateTime, locale)} />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}> <Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}>
{selectedMessage.isRead ? '已读' : '未读'} {selectedMessage.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge> </Badge>
</div> </div>
</div> </div>
<div className="overflow-hidden rounded-2xl border bg-background"> <div className="overflow-hidden rounded-2xl border bg-background">
<div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground"> <div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{t('正文', 'Body')}
</div> </div>
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? ( {selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (
<iframe <iframe
title="邮件正文" title={t('邮件正文', 'Message body')}
srcDoc={buildEmailPreviewDocument( srcDoc={buildEmailPreviewDocument(
selectedMessage.body?.content || selectedMessage.bodyPreview || '' selectedMessage.body?.content || selectedMessage.bodyPreview || '',
locale
)} )}
className="h-[60vh] w-full bg-white" className="h-[60vh] w-full bg-white"
sandbox="allow-popups allow-popups-to-escape-sandbox" sandbox="allow-popups allow-popups-to-escape-sandbox"
@ -1086,7 +1104,7 @@ export default function OutlookPage() {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground">{t('未加载到邮件详情。', 'Message details were not loaded.')}</p>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -1094,26 +1112,26 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}> <Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{selectedEvent?.subject || '日程详情'}</DialogTitle> <DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedEvent {selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime)} - ${formatDateTime(selectedEvent.end?.dateTime)}` ? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
: '日程详情'} : t('日程详情', 'Event details')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{selectedEvent && ( {selectedEvent && (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
<InfoRow label="组织者" value={mailboxLabel(selectedEvent.organizer)} /> <InfoRow label={t('组织者', 'Organizer')} value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label="地点" value={selectedEvent.location?.displayName || '-'} /> <InfoRow label={t('地点', 'Location')} value={selectedEvent.location?.displayName || '-'} />
<InfoRow <InfoRow
label="参会人" label={t('参会人', 'Attendees')}
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join('') || '-'} value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/> />
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('说明', 'Notes')}</p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap"> <div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
{selectedEvent.bodyPreview || '没有更多说明。'} {selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div> </div>
</div> </div>
</div> </div>
@ -1185,6 +1203,7 @@ function MessageCard({
icon, icon,
items, items,
page, page,
locale,
loading = false, loading = false,
emptyLabel, emptyLabel,
onOpen, onOpen,
@ -1197,6 +1216,7 @@ function MessageCard({
icon: React.ReactNode; icon: React.ReactNode;
items: OutlookMessageSummary[]; items: OutlookMessageSummary[];
page: OutlookPageInfo | null; page: OutlookPageInfo | null;
locale: AppLocale;
loading?: boolean; loading?: boolean;
emptyLabel: string; emptyLabel: string;
onOpen: (item: OutlookMessageSummary) => void; onOpen: (item: OutlookMessageSummary) => void;
@ -1205,8 +1225,9 @@ function MessageCard({
onPreviousPage: () => void; onPreviousPage: () => void;
onNextPage: () => void; onNextPage: () => void;
}) { }) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1; const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
const pageLabel = page ? `${currentPage} 页 · 本页 ${page.returned}` : '正在读取邮件…'; const pageLabel = page ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return ( return (
<Card className="rounded-[28px] shadow-sm"> <Card className="rounded-[28px] shadow-sm">
@ -1216,14 +1237,14 @@ function MessageCard({
{icon} {icon}
{title} {title}
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p> <p className="text-sm text-muted-foreground">{loading ? t('正在读取邮件…', 'Loading messages...') : pageLabel}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}> <Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button> </Button>
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}> <Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
{t('上一页', 'Previous')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -1231,7 +1252,7 @@ function MessageCard({
onClick={onNextPage} onClick={onNextPage}
disabled={!page || !page.has_more || refreshing} disabled={!page || !page.has_more || refreshing}
> >
{t('下一页', 'Next')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@ -1262,17 +1283,17 @@ function MessageCard({
> >
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{item.subject || '(无主题)'}</p> <p className="truncate font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p> <p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground"> <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.bodyPreview || '没有预览内容。'} {item.bodyPreview || t('没有预览内容。', 'No preview available.')}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end"> <div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
<Badge variant={item.isRead ? 'secondary' : 'default'}> <Badge variant={item.isRead ? 'secondary' : 'default'}>
{item.isRead ? '已读' : '未读'} {item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge> </Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime)}</span> <span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime, locale)}</span>
</div> </div>
</div> </div>
</button> </button>
@ -1287,6 +1308,7 @@ function MessageCard({
function EventCard({ function EventCard({
items, items,
startDate, startDate,
locale,
loading = false, loading = false,
onOpen, onOpen,
onRefresh, onRefresh,
@ -1297,6 +1319,7 @@ function EventCard({
}: { }: {
items: OutlookEventSummary[]; items: OutlookEventSummary[];
startDate?: string | null; startDate?: string | null;
locale: AppLocale;
loading?: boolean; loading?: boolean;
onOpen: (item: OutlookEventSummary) => void; onOpen: (item: OutlookEventSummary) => void;
onRefresh: () => void; onRefresh: () => void;
@ -1305,6 +1328,7 @@ function EventCard({
onNextWeek: () => void; onNextWeek: () => void;
onCurrentWeek: () => void; onCurrentWeek: () => void;
}) { }) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const initialAnchor = startDate ? new Date(startDate) : new Date(); const initialAnchor = startDate ? new Date(startDate) : new Date();
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor; const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
const weekDays = Array.from({ length: 7 }, (_, index) => { const weekDays = Array.from({ length: 7 }, (_, index) => {
@ -1316,7 +1340,7 @@ function EventCard({
const key = toLocalDateKey(day); const key = toLocalDateKey(day);
return { return {
key, key,
label: formatDayLabel(day), label: formatDayLabel(day, locale),
items: items items: items
.filter((item) => formatDateKey(item.start?.dateTime) === key) .filter((item) => formatDateKey(item.start?.dateTime) === key)
.sort((left, right) => { .sort((left, right) => {
@ -1333,21 +1357,21 @@ function EventCard({
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
{t('日程安排', 'Schedule')}
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])} {formatDayLabel(weekDays[0], locale)} - {formatDayLabel(weekDays[weekDays.length - 1], locale)}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}> <Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
{t('上一周', 'Previous week')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}> <Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
{t('本周', 'This week')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}> <Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
{t('下一周', 'Next week')}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}> <Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
@ -1372,11 +1396,11 @@ function EventCard({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="font-medium text-foreground">{day.label}</p> <p className="font-medium text-foreground">{day.label}</p>
<p className="text-xs text-muted-foreground">{day.items.length} </p> <p className="text-xs text-muted-foreground">{t(`${day.items.length} 条安排`, `${day.items.length} events`)}</p>
</div> </div>
</div> </div>
{day.items.length === 0 ? ( {day.items.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground"></p> <p className="mt-4 text-sm text-muted-foreground">{t('暂无安排', 'No events')}</p>
) : ( ) : (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{day.items.map((item) => ( {day.items.map((item) => (
@ -1386,12 +1410,12 @@ function EventCard({
onClick={() => onOpen(item)} onClick={() => onOpen(item)}
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40" className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
> >
<p className="font-medium text-foreground">{item.subject || '(无主题)'}</p> <p className="font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime)} - {formatTime(item.end?.dateTime)} {formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p> </p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{item.location?.displayName || '未设置地点'} {item.location?.displayName || t('未设置地点', 'No location set')}
</p> </p>
</button> </button>
))} ))}

View File

@ -14,7 +14,6 @@ import {
createSession, createSession,
deleteSession, deleteSession,
getSession, getSession,
getStatus,
listCommands, listCommands,
listSessions, listSessions,
sendMessage, sendMessage,
@ -22,29 +21,10 @@ import {
wsManager, wsManager,
} from '@/lib/api'; } from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office'; import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types'; import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
function messageFingerprint(msg: ChatMessage): string { function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? []) const attachmentKey = (msg.attachments ?? [])
@ -76,16 +56,12 @@ function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessage
return [...serverMessages, ...pendingUsers]; return [...serverMessages, ...pendingUsers];
} }
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent { function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string'; return data.type === 'session_updated' && typeof data.session_id === 'string';
} }
export default function ChatPage() { export default function ChatPage() {
const { locale } = useAppI18n();
const { const {
sessionId, sessionId,
messages, messages,
@ -100,13 +76,8 @@ export default function ChatPage() {
setMessages, setMessages,
addMessage, addMessage,
setIsLoading, setIsLoading,
setSessions,
clearMessages, clearMessages,
setWsStatus,
setIsThinking, setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId, setSelectedRunId,
} = useChatStore(); } = useChatStore();
@ -124,9 +95,8 @@ export default function ChatPage() {
const commandsLoadedRef = useRef(false); const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false); const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false); const hasConnectedRef = useRef(false);
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
const statusCheckInFlightRef = useRef(false);
const shouldSnapToLatestRef = useRef(true); const shouldSnapToLatestRef = useRef(true);
const wsStatus = useChatStore((state) => state.wsStatus);
const filteredCommands = useMemo(() => { const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return []; if (!input.startsWith('/') || input.includes(' ')) return [];
@ -136,6 +106,28 @@ export default function ChatPage() {
); );
}, [commands, input]); }, [commands, input]);
const sessionProcessRuns = useMemo(
() => processRuns.filter((run) => run.session_id === sessionId),
[processRuns, sessionId]
);
const sessionRunIds = useMemo(
() => new Set(sessionProcessRuns.map((run) => run.run_id)),
[sessionProcessRuns]
);
const sessionProcessEvents = useMemo(
() => processEvents.filter((event) => sessionRunIds.has(event.run_id)),
[processEvents, sessionRunIds]
);
const sessionProcessArtifacts = useMemo(
() => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)),
[processArtifacts, sessionRunIds]
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const officeTasks = useMemo( const officeTasks = useMemo(
() => buildOfficeTaskList({ () => buildOfficeTaskList({
sessionId, sessionId,
@ -143,8 +135,8 @@ export default function ChatPage() {
processRuns, processRuns,
processEvents, processEvents,
processArtifacts, processArtifacts,
}), }, locale),
[processArtifacts, processEvents, processRuns, sessionId, sessions] [locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
); );
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null; const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
@ -152,11 +144,11 @@ export default function ChatPage() {
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const list = await listSessions(); const list = await listSessions();
setSessions(list); useChatStore.getState().setSessions(list);
} catch { } catch {
// backend may be offline during first render // backend may be offline during first render
} }
}, [setSessions]); }, []);
const loadSessionMessages = useCallback(async (key: string) => { const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current; const reqSeq = ++loadSessionReqSeq.current;
@ -170,6 +162,7 @@ export default function ChatPage() {
? mergeServerWithPendingUsers(detail.messages, localSnapshot) ? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages; : detail.messages;
setMessages(nextMessages); setMessages(nextMessages);
shouldSnapToLatestRef.current = true;
const last = nextMessages[nextMessages.length - 1]; const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') { if (last?.role === 'assistant') {
setIsThinking(false); setIsThinking(false);
@ -192,23 +185,6 @@ export default function ChatPage() {
} }
}, []); }, []);
const scheduleStatusCheck = useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
useEffect(() => { useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) { if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands(); void loadCommands();
@ -220,40 +196,29 @@ export default function ChatPage() {
setPickerIndex(0); setPickerIndex(0);
}, [filteredCommands]); }, [filteredCommands]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => { useEffect(() => {
clearMessages(); clearMessages();
setIsLoading(false); setIsLoading(false);
setIsThinking(false); setIsThinking(false);
resetProcessState(); void loadSessionMessages(sessionId);
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId; }, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
wsManager.connect(wsSessionId);
loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, resetProcessState, sessionId, setIsLoading, setIsThinking]);
useEffect(() => { useEffect(() => {
const unsubStatus = wsManager.onStatusChange(async (status) => { if (wsStatus === 'connected') {
setWsStatus(status); if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
if (status === 'connected') { refreshSessionOnReconnectRef.current = false;
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) { void loadSessionMessages(useChatStore.getState().sessionId);
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
hasConnectedRef.current = true;
scheduleStatusCheck();
} else {
if (status === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
} }
}); hasConnectedRef.current = true;
return;
}
if (wsStatus === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
}, [loadSessionMessages, wsStatus]);
useEffect(() => {
const unsubMessage = wsManager.onMessage((data) => { const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) { if (isSessionUpdatedEvent(data)) {
void loadSessions(); void loadSessions();
@ -263,11 +228,6 @@ export default function ChatPage() {
return; return;
} }
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') { if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true); setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') { } else if (data.type === 'message' && data.role === 'assistant') {
@ -284,12 +244,9 @@ export default function ChatPage() {
}); });
return () => { return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage(); unsubMessage();
}; };
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]); }, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => { useEffect(() => {
if (!isLoading && !isThinking) { if (!isLoading && !isThinking) {
@ -304,21 +261,34 @@ export default function ChatPage() {
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => { const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current; const viewport = messageViewportRef.current;
if (!viewport) return; if (!viewport) return;
messagesEndRef.current?.scrollIntoView({ block: 'end', behavior });
viewport.scrollTo({ top: viewport.scrollHeight, behavior }); viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []); }, []);
const scheduleScrollToLatest = useCallback((behavior: ScrollBehavior) => {
if (typeof window === 'undefined') {
scrollMessagesToLatest(behavior);
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollMessagesToLatest(behavior);
});
});
}, [scrollMessagesToLatest]);
useEffect(() => { useEffect(() => {
shouldSnapToLatestRef.current = true; shouldSnapToLatestRef.current = true;
}, [sessionId]); }, [sessionId]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) { if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) {
return; return;
} }
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth'); scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false; shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]); }, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
useEffect(() => { useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return; if (!showCommandPicker || !pickerRef.current) return;
@ -348,7 +318,7 @@ export default function ChatPage() {
setPendingFiles([]); setPendingFiles([]);
setShowCommandPicker(false); setShowCommandPicker(false);
const msgContent = text || '(仅附件)'; const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
addMessage({ addMessage({
role: 'user', role: 'user',
content: msgContent, content: msgContent,
@ -392,12 +362,12 @@ export default function ChatPage() {
} }
addMessage({ addMessage({
role: 'assistant', role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。', content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
} }
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]); }, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) { if (showCommandPicker && filteredCommands.length > 0) {
@ -436,7 +406,7 @@ export default function ChatPage() {
for (const file of files) { for (const file of files) {
if (file.size > 50 * 1024 * 1024) { if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]); setPendingFiles((prev) => [...prev, { file, progress: 0, error: pickAppText(locale, '文件过大(最大 50MB', 'File is too large (max 50MB)') }]);
continue; continue;
} }
@ -447,16 +417,17 @@ export default function ChatPage() {
}); });
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item))); setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
} catch (err: any) { } catch (err: any) {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item))); setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || pickAppText(locale, '上传失败', 'Upload failed') } : item)));
} }
} }
}, [sessionId]); }, [locale, sessionId]);
const handleNewSession = async () => { const handleNewSession = async () => {
const id = `web:${Date.now()}`; const id = `web:${Date.now()}`;
setSessionId(id); setSessionId(id);
setSelectedRunId(null);
clearMessages(); clearMessages();
resetProcessState(); useChatStore.getState().resetProcessState();
try { try {
await createSession(id); await createSession(id);
} catch { } catch {
@ -472,7 +443,7 @@ export default function ChatPage() {
if (key === sessionId) { if (key === sessionId) {
setSessionId('web:default'); setSessionId('web:default');
clearMessages(); clearMessages();
resetProcessState(); useChatStore.getState().resetProcessState();
} }
loadSessions(); loadSessions();
} catch { } catch {
@ -481,20 +452,21 @@ export default function ChatPage() {
}; };
const handleSelectSession = (key: string) => { const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setSessionId(key); setSessionId(key);
}; };
const handleCancelRun = async (runId: string) => { const handleCancelRun = useCallback(async (runId: string) => {
try { try {
await cancelDelegation(runId); await cancelDelegation(runId);
} catch (err: any) { } catch (err: any) {
addMessage({ addMessage({
role: 'assistant', role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`, content: pickAppText(locale, `取消任务 ${runId} 失败:${err.message || '未知错误'}`, `Failed to cancel task ${runId}: ${err.message || 'Unknown error'}`),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
}; }, [addMessage, locale]);
const removePendingFile = useCallback((file: File) => { const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file)); setPendingFiles((prev) => prev.filter((item) => item.file !== file));
@ -503,10 +475,10 @@ export default function ChatPage() {
const formatSessionName = (key: string) => { const formatSessionName = (key: string) => {
if (key.startsWith('web:')) { if (key.startsWith('web:')) {
const id = key.slice(4); const id = key.slice(4);
if (id === 'default') return '默认'; if (id === 'default') return pickAppText(locale, '默认', 'Default');
const numeric = Number(id); const numeric = Number(id);
if (!Number.isNaN(numeric)) { if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', { return new Date(numeric).toLocaleDateString(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@ -524,14 +496,14 @@ export default function ChatPage() {
<div className="p-3"> <div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm"> <Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
{pickAppText(locale, '新对话', 'New chat')}
</Button> </Button>
</div> </div>
<Separator /> <Separator />
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{sessions.length === 0 && ( {sessions.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4 text-center"></p> <p className="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)} )}
{sessions.map((session) => ( {sessions.map((session) => (
<div <div
@ -567,22 +539,22 @@ export default function ChatPage() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
{pickAppText(locale, '当前任务现场', 'Current task floor')}
</div> </div>
<OfficeStatusBadge status={currentOfficeTask.status} /> <OfficeStatusBadge status={currentOfficeTask.status} />
</div> </div>
<div className="mt-1 truncate text-sm text-muted-foreground"> <div className="mt-1 truncate text-sm text-muted-foreground">
{currentOfficeTask.title} {currentOfficeTask.title}
<span className="ml-2"> Agent: {currentOfficeTask.rootActorName}</span> <span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href="/office"> Office</Link> <Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
</Button> </Button>
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}> <Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
{pickAppText(locale, '查看任务现场', 'Open task floor')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
@ -597,11 +569,11 @@ export default function ChatPage() {
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')} isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef} messageViewportRef={messageViewportRef}
processRuns={processRuns} processRuns={sessionProcessRuns}
processEvents={processEvents} processEvents={sessionProcessEvents}
processArtifacts={processArtifacts} processArtifacts={sessionProcessArtifacts}
selectedRunId={selectedRunId} selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)} onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onCancelRun={handleCancelRun} onCancelRun={handleCancelRun}
/> />
</div> </div>
@ -623,7 +595,7 @@ export default function ChatPage() {
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} /> <div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div> </div>
) : ( ) : (
<span className="text-green-500 text-xs"></span> <span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
)} )}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground"> <button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@ -660,7 +632,7 @@ export default function ChatPage() {
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span> <span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && ( {command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}> <span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? '技能' : command.plugin_name} {command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
</span> </span>
)} )}
</button> </button>
@ -675,7 +647,7 @@ export default function ChatPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 flex-shrink-0" className="h-10 w-10 flex-shrink-0"
title="添加附件" title={pickAppText(locale, '添加附件', 'Add attachment')}
> >
<Paperclip className="w-4 h-4" /> <Paperclip className="w-4 h-4" />
</Button> </Button>
@ -685,7 +657,7 @@ export default function ChatPage() {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)" placeholder={pickAppText(locale, '输入消息或 / 呼出命令…回车发送Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
rows={1} rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }} style={{ minHeight: '40px', maxHeight: '200px' }}

View File

@ -19,8 +19,11 @@ 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 { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { PluginInfo } from '@/types'; import type { PluginInfo } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function PluginsPage() { export default function PluginsPage() {
const { locale } = useAppI18n();
const [plugins, setPlugins] = useState<PluginInfo[]>([]); const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -32,7 +35,7 @@ export default function PluginsPage() {
const data = await listPlugins(); const data = await listPlugins();
setPlugins(Array.isArray(data) ? data : []); setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载插件失败'); setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -57,15 +60,16 @@ export default function PluginsPage() {
<div> <div>
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Blocks className="w-6 h-6" /> <Blocks className="w-6 h-6" />
{pickAppText(locale, '插件', 'Plugins')}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
workspace <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code> {pickAppText(locale, '已安装位置:全局插件目录或当前 workspace 的 ', 'Installed from the global plugin directory or this workspace\'s ')}
<code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
</p> </p>
</div> </div>
<Button onClick={load} variant="outline" size="sm"> <Button onClick={load} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button> </Button>
</div> </div>
@ -86,10 +90,11 @@ export default function PluginsPage() {
<Card> <Card>
<CardContent className="py-16 text-center text-muted-foreground"> <CardContent className="py-16 text-center text-muted-foreground">
<Blocks className="w-12 h-12 mx-auto mb-4 opacity-30" /> <Blocks className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p> <p className="font-medium">{pickAppText(locale, '还没有安装任何插件', 'No plugins are installed yet')}</p>
<p className="text-sm mt-2 max-w-sm mx-auto"> <p className="text-sm mt-2 max-w-sm mx-auto">
workspace <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code> {pickAppText(locale, '把插件目录放到全局插件目录或当前 workspace 的 ', 'Put a plugin directory in the global plugin directory or this workspace\'s ')}
Boardware Agent Sandbox <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
{pickAppText(locale, ',然后重启 Boardware Agent Sandbox。', ', then restart Boardware Agent Sandbox.')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -106,6 +111,7 @@ export default function PluginsPage() {
} }
function PluginCard({ plugin }: { plugin: PluginInfo }) { function PluginCard({ plugin }: { plugin: PluginInfo }) {
const { locale } = useAppI18n();
const [agentsOpen, setAgentsOpen] = useState(true); const [agentsOpen, setAgentsOpen] = useState(true);
const [commandsOpen, setCommandsOpen] = useState(true); const [commandsOpen, setCommandsOpen] = useState(true);
const [skillsOpen, setSkillsOpen] = useState(false); const [skillsOpen, setSkillsOpen] = useState(false);
@ -132,19 +138,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.agents.length > 0 && ( {plugin.agents.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Bot className="w-3 h-3" /> <Bot className="w-3 h-3" />
{plugin.agents.length} {pickAppText(locale, `${plugin.agents.length} 个智能体`, `${plugin.agents.length} agents`)}
</span> </span>
)} )}
{plugin.commands.length > 0 && ( {plugin.commands.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Terminal className="w-3 h-3" /> <Terminal className="w-3 h-3" />
{plugin.commands.length} {pickAppText(locale, `${plugin.commands.length} 条命令`, `${plugin.commands.length} commands`)}
</span> </span>
)} )}
{plugin.skills.length > 0 && ( {plugin.skills.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Wrench className="w-3 h-3" /> <Wrench className="w-3 h-3" />
{plugin.skills.length} {pickAppText(locale, `${plugin.skills.length} 个技能`, `${plugin.skills.length} skills`)}
</span> </span>
)} )}
</div> </div>
@ -157,7 +163,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.agents.length > 0 && ( {plugin.agents.length > 0 && (
<Section <Section
icon={<Bot className="w-3.5 h-3.5" />} icon={<Bot className="w-3.5 h-3.5" />}
label="智能体" label={pickAppText(locale, '智能体', 'Agents')}
count={plugin.agents.length} count={plugin.agents.length}
open={agentsOpen} open={agentsOpen}
onToggle={() => setAgentsOpen((v) => !v)} onToggle={() => setAgentsOpen((v) => !v)}
@ -186,7 +192,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.commands.length > 0 && ( {plugin.commands.length > 0 && (
<Section <Section
icon={<Terminal className="w-3.5 h-3.5" />} icon={<Terminal className="w-3.5 h-3.5" />}
label="命令" label={pickAppText(locale, '命令', 'Commands')}
count={plugin.commands.length} count={plugin.commands.length}
open={commandsOpen} open={commandsOpen}
onToggle={() => setCommandsOpen((v) => !v)} onToggle={() => setCommandsOpen((v) => !v)}
@ -213,7 +219,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.skills.length > 0 && ( {plugin.skills.length > 0 && (
<Section <Section
icon={<Wrench className="w-3.5 h-3.5" />} icon={<Wrench className="w-3.5 h-3.5" />}
label="技能" label={pickAppText(locale, '技能', 'Skills')}
count={plugin.skills.length} count={plugin.skills.length}
open={skillsOpen} open={skillsOpen}
onToggle={() => setSkillsOpen((v) => !v)} onToggle={() => setSkillsOpen((v) => !v)}
@ -234,18 +240,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
} }
function SourceBadge({ source }: { source: 'global' | 'workspace' }) { function SourceBadge({ source }: { source: 'global' | 'workspace' }) {
const { locale } = useAppI18n();
if (source === 'workspace') { if (source === 'workspace') {
return ( return (
<Badge variant="default" className="text-xs gap-1"> <Badge variant="default" className="text-xs gap-1">
<FolderOpen className="w-3 h-3" /> <FolderOpen className="w-3 h-3" />
{pickAppText(locale, '工作区', 'Workspace')}
</Badge> </Badge>
); );
} }
return ( return (
<Badge variant="secondary" className="text-xs gap-1"> <Badge variant="secondary" className="text-xs gap-1">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
{pickAppText(locale, '全局', 'Global')}
</Badge> </Badge>
); );
} }

View File

@ -24,8 +24,11 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import type { Skill } from '@/types'; import type { Skill } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function SkillsPage() { export default function SkillsPage() {
const { locale } = useAppI18n();
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -39,7 +42,7 @@ export default function SkillsPage() {
const data = await listSkills(); const data = await listSkills();
setSkills(Array.isArray(data) ? data : []); setSkills(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载技能失败'); setError(err.message || pickAppText(locale, '加载技能失败', 'Failed to load skills'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -59,7 +62,7 @@ export default function SkillsPage() {
setDeleting(null); setDeleting(null);
loadSkills(); loadSkills();
} catch (err: any) { } catch (err: any) {
setError(err.message || '删除技能失败'); setError(err.message || pickAppText(locale, '删除技能失败', 'Failed to delete the skill'));
setDeleting(null); setDeleting(null);
} }
}; };
@ -82,16 +85,16 @@ export default function SkillsPage() {
<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">
<Puzzle className="w-6 h-6" /> <Puzzle className="w-6 h-6" />
{pickAppText(locale, '技能', 'Skills')}
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={loadSkills} variant="outline" size="sm"> <Button onClick={loadSkills} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button> </Button>
<Button onClick={() => setShowUpload(true)} size="sm"> <Button onClick={() => setShowUpload(true)} size="sm">
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
{pickAppText(locale, '上传技能', 'Upload skill')}
</Button> </Button>
</div> </div>
</div> </div>
@ -122,7 +125,7 @@ export default function SkillsPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm"> <p className="text-sm">
<strong>{deleting}</strong> {pickAppText(locale, '确定删除技能', 'Delete skill')} <strong>{deleting}</strong> {pickAppText(locale, '吗?此操作不可撤销。', '? This action cannot be undone.')}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@ -130,14 +133,14 @@ export default function SkillsPage() {
size="sm" size="sm"
onClick={() => setDeleting(null)} onClick={() => setDeleting(null)}
> >
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => confirmDelete(deleting)} onClick={() => confirmDelete(deleting)}
> >
{pickAppText(locale, '删除', 'Delete')}
</Button> </Button>
</div> </div>
</div> </div>
@ -151,18 +154,18 @@ export default function SkillsPage() {
{skills.length === 0 ? ( {skills.length === 0 ? (
<div className="py-12 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" /> <Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p> <p className="font-medium">{pickAppText(locale, '暂无技能', 'No skills yet')}</p>
<p className="text-sm mt-1"> zip 使</p> <p className="text-sm mt-1">{pickAppText(locale, '上传一个技能 zip 包即可开始使用。', 'Upload a skill zip package to get started.')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '描述', 'Description')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
<TableHead></TableHead> <TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24"></TableHead> <TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -177,22 +180,22 @@ export default function SkillsPage() {
<TableCell> <TableCell>
{skill.source === 'builtin' ? ( {skill.source === 'builtin' ? (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{pickAppText(locale, '内置', 'Built in')}
</Badge> </Badge>
) : ( ) : (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
{pickAppText(locale, '工作区', 'Workspace')}
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{skill.available ? ( {skill.available ? (
<Badge variant="default" className="text-xs bg-green-600"> <Badge variant="default" className="text-xs bg-green-600">
{pickAppText(locale, '可用', 'Available')}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-xs text-muted-foreground"> <Badge variant="outline" className="text-xs text-muted-foreground">
{pickAppText(locale, '不可用', 'Unavailable')}
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
@ -202,7 +205,7 @@ export default function SkillsPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
title="下载" title={pickAppText(locale, '下载', 'Download')}
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))} onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
@ -213,7 +216,7 @@ export default function SkillsPage() {
size="icon" size="icon"
className="h-7 w-7 text-destructive hover:text-destructive" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(skill.name)} onClick={() => handleDelete(skill.name)}
title="删除" title={pickAppText(locale, '删除', 'Delete')}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</Button> </Button>
@ -240,6 +243,7 @@ function UploadSkillForm({
onCancel: () => void; onCancel: () => void;
onError: (msg: string) => void; onError: (msg: string) => void;
}) { }) {
const { locale } = useAppI18n();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@ -253,7 +257,7 @@ function UploadSkillForm({
await uploadSkill(file); await uploadSkill(file);
onDone(); onDone();
} catch (err: any) { } catch (err: any) {
onError(err.message || '上传失败'); onError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally { } finally {
setUploading(false); setUploading(false);
} }
@ -263,7 +267,7 @@ function UploadSkillForm({
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base">{pickAppText(locale, '上传技能', 'Upload skill')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
@ -273,7 +277,7 @@ function UploadSkillForm({
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="skill-zip"> <label className="text-sm font-medium" htmlFor="skill-zip">
{pickAppText(locale, '技能压缩包', 'Skill archive')}
</label> </label>
<input <input
id="skill-zip" id="skill-zip"
@ -283,23 +287,23 @@ function UploadSkillForm({
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer" className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
`SKILL.md` {pickAppText(locale, '压缩包中必须包含 `SKILL.md` 文件', 'The archive must contain a `SKILL.md` file')}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
<Button type="submit" disabled={uploading}> <Button type="submit" disabled={uploading}>
{uploading ? ( {uploading ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
... {pickAppText(locale, '上传中...', 'Uploading...')}
</> </>
) : ( ) : (
<> <>
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
{pickAppText(locale, '上传', 'Upload')}
</> </>
)} )}
</Button> </Button>

View File

@ -27,8 +27,11 @@ 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 { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { SystemStatus } from '@/types'; import type { SystemStatus } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function StatusPage() { export default function StatusPage() {
const { locale } = useAppI18n();
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -43,7 +46,7 @@ export default function StatusPage() {
const data = await getStatus(); const data = await getStatus();
setStatus(data); setStatus(data);
} catch (err: any) { } catch (err: any) {
setError(err.message || '连接后端失败'); setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -79,7 +82,7 @@ export default function StatusPage() {
setRestartDialogOpen(false); setRestartDialogOpen(false);
setRestarting(true); setRestarting(true);
} catch (err: any) { } catch (err: any) {
setRestartError(err.message || '重启失败'); setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed'));
} }
}; };
@ -99,16 +102,14 @@ export default function StatusPage() {
<div className="flex items-center gap-3 text-destructive"> <div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" /> <AlertCircle className="w-5 h-5" />
<div> <div>
<p className="font-medium"> Boardware Agent Sandbox </p> <p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p> <p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
访
</p>
</div> </div>
</div> </div>
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4"> <Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '重试', 'Retry')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -121,10 +122,10 @@ export default function StatusPage() {
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold">{pickAppText(locale, '系统状态', 'System status')}</h1>
<Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}> <Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}>
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button> </Button>
</div> </div>
@ -133,17 +134,17 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Server className="w-4 h-4" /> <Server className="w-4 h-4" />
{pickAppText(locale, '系统信息', 'System information')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium"></p> <p className="text-sm font-medium">{pickAppText(locale, '重启当前实例', 'Restart current instance')}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{restarting {restarting
? '正在重启当前 docker服务恢复后页面会自动刷新。' ? pickAppText(locale, '正在重启当前 docker服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.')
: '会重启当前 docker 容器。重启完成后需要重新登录。'} : pickAppText(locale, '会重启当前 docker 容器。重启完成后需要重新登录。', 'This restarts the current Docker container. You will need to sign in again afterwards.')}
</p> </p>
{restartError ? ( {restartError ? (
<p className="text-sm text-destructive">{restartError}</p> <p className="text-sm text-destructive">{restartError}</p>
@ -164,15 +165,15 @@ export default function StatusPage() {
</Button> </Button>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
docker {pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={restarting}></AlertDialogCancel> <AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRestart} disabled={restarting}> <AlertDialogAction onClick={handleRestart} disabled={restarting}>
{restarting ? '重启中...' : '确认 Restart'} {restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -186,15 +187,15 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Cpu className="w-4 h-4" /> <Cpu className="w-4 h-4" />
{pickAppText(locale, '智能体配置', 'Agent configuration')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<InfoRow label="模型" value={status.model} /> <InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
<InfoRow label="最大令牌数" value={String(status.max_tokens)} /> <InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
<InfoRow label="温度" value={String(status.temperature)} /> <InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
<InfoRow <InfoRow
label="最大工具迭代次数" label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
value={String(status.max_tool_iterations)} value={String(status.max_tool_iterations)}
/> />
</CardContent> </CardContent>
@ -205,7 +206,7 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Key className="w-4 h-4" /> <Key className="w-4 h-4" />
{pickAppText(locale, '提供商', 'Providers')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -239,7 +240,7 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Radio className="w-4 h-4" /> <Radio className="w-4 h-4" />
{pickAppText(locale, '通道', 'Channels')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -250,7 +251,7 @@ export default function StatusPage() {
variant={ch.enabled ? 'default' : 'secondary'} variant={ch.enabled ? 'default' : 'secondary'}
className="text-xs" className="text-xs"
> >
{ch.enabled ? '开启' : '关闭'} {ch.enabled ? pickAppText(locale, '开启', 'On') : pickAppText(locale, '关闭', 'Off')}
</Badge> </Badge>
<span className="capitalize">{ch.name}</span> <span className="capitalize">{ch.name}</span>
</div> </div>
@ -264,16 +265,16 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
{pickAppText(locale, '调度器', 'Scheduler')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<InfoRow <InfoRow
label="状态" label={pickAppText(locale, '状态', 'Status')}
value={status.cron.enabled ? '运行中' : '已停止'} value={status.cron.enabled ? pickAppText(locale, '运行中', 'Running') : pickAppText(locale, '已停止', 'Stopped')}
ok={status.cron.enabled} ok={status.cron.enabled}
/> />
<InfoRow label="任务数" value={String(status.cron.jobs)} /> <InfoRow label={pickAppText(locale, '任务数', 'Jobs')} value={String(status.cron.jobs)} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -4,8 +4,11 @@ import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal'; import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function LoginRedirectPage() { export default function LoginRedirectPage() {
const { locale } = useAppI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
@ -15,7 +18,9 @@ export default function LoginRedirectPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center px-4"> <div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">...</div> <div className="text-sm text-muted-foreground">
{pickAppText(locale, '正在跳转到登录门户...', 'Redirecting to the sign-in portal...')}
</div>
</div> </div>
); );
} }

View File

@ -4,8 +4,11 @@ import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal'; import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function RegisterRedirectPage() { export default function RegisterRedirectPage() {
const { locale } = useAppI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
@ -15,7 +18,9 @@ export default function RegisterRedirectPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center px-4"> <div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">...</div> <div className="text-sm text-muted-foreground">
{pickAppText(locale, '正在跳转到注册门户...', 'Redirecting to the sign-up portal...')}
</div>
</div> </div>
); );
} }

View File

@ -4,6 +4,8 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { clearTokens, consumeHandoffCode, getMe, setTokens } from '@/lib/api'; import { clearTokens, consumeHandoffCode, getMe, setTokens } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
const HANDOFF_STATE_KEY = 'nanobot_handoff_state'; const HANDOFF_STATE_KEY = 'nanobot_handoff_state';
@ -78,6 +80,7 @@ function clearHandoffState(): void {
} }
export default function HandoffPage() { export default function HandoffPage() {
const { locale } = useAppI18n();
const router = useRouter(); const router = useRouter();
const setUser = useChatStore((s) => s.setUser); const setUser = useChatStore((s) => s.setUser);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -91,7 +94,7 @@ export default function HandoffPage() {
if (!handoff.code && !handoff.accessToken) { if (!handoff.code && !handoff.accessToken) {
clearHandoffState(); clearHandoffState();
setError('缺少登录凭证,无法进入目标前端。'); setError(pickAppText(locale, '缺少登录凭证,无法进入目标前端。', 'Missing login credentials. Unable to enter the target frontend.'));
return; return;
} }
@ -116,7 +119,7 @@ export default function HandoffPage() {
clearHandoffState(); clearHandoffState();
clearTokens(); clearTokens();
if (cancelled) return; if (cancelled) return;
setError(err instanceof Error ? err.message : '目标前端登录失败'); setError(err instanceof Error ? err.message : pickAppText(locale, '目标前端登录失败', 'Target frontend sign-in failed'));
} }
}; };
@ -129,8 +132,14 @@ export default function HandoffPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center px-4"> <div className="flex min-h-screen items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<h1 className="text-xl font-semibold">...</h1> <h1 className="text-xl font-semibold">{pickAppText(locale, '正在进入目标前端...', 'Entering the target frontend...')}</h1>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : <p className="mt-3 text-sm text-muted-foreground"></p>} {error ? (
<p className="mt-3 text-sm text-red-400">{error}</p>
) : (
<p className="mt-3 text-sm text-muted-foreground">
{pickAppText(locale, '正在同步登录态,请稍候。', 'Syncing your sign-in state. Please wait.')}
</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,9 +1,11 @@
import './globals.css'; import './globals.css';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { AppI18nProvider } from '@/lib/i18n/provider';
import { getServerAppLocale } from '@/lib/i18n/server';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Boardware Agent Sandbox', title: 'Boardware Agent Sandbox',
description: '个人 AI 助手', description: 'Boardware Agent Sandbox',
icons: { icons: {
icon: '/boardware-logo.jpg', icon: '/boardware-logo.jpg',
}, },
@ -14,9 +16,13 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const locale = getServerAppLocale();
return ( return (
<html lang="zh-CN" className="dark"> <html lang={locale} className="dark">
<body className="bg-background text-foreground">{children}</body> <body className="bg-background text-foreground">
<AppI18nProvider initialLocale={locale}>{children}</AppI18nProvider>
</body>
</html> </html>
); );
} }

View File

@ -0,0 +1,122 @@
'use client';
import React from 'react';
import { getStatus, listSessions, wsManager } from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { ProcessWsEvent, SessionUpdatedEvent, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
}
export function AppRuntimeBridge() {
const sessionId = useChatStore((state) => state.sessionId);
const setSessions = useChatStore((state) => state.setSessions);
const setWsStatus = useChatStore((state) => state.setWsStatus);
const setNanobotReady = useChatStore((state) => state.setNanobotReady);
const resetProcessState = useChatStore((state) => state.resetProcessState);
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
const statusCheckInFlightRef = React.useRef(false);
const loadSessions = React.useCallback(async () => {
try {
const sessions = await listSessions();
setSessions(sessions);
} catch {
// backend may still be offline during first render
}
}, [setSessions]);
const scheduleStatusCheck = React.useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
React.useEffect(() => {
void loadSessions();
}, [loadSessions]);
React.useEffect(() => {
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
}, [resetProcessState, sessionId]);
React.useEffect(() => {
const unsubStatus = wsManager.onStatusChange((status) => {
setWsStatus(status);
if (status === 'connected') {
scheduleStatusCheck();
} else {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
}
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
};
}, [scheduleStatusCheck, setNanobotReady, setWsStatus]);
React.useEffect(() => {
const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) {
void loadSessions();
return;
}
if (isProcessEvent(data)) {
ingestProcessEvent(data);
}
});
return () => {
unsubMessage();
};
}, [ingestProcessEvent, loadSessions]);
return null;
}

View File

@ -4,6 +4,8 @@ import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal'; import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { clearTokens, getMe, isLoggedIn } from '@/lib/api'; import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
export default function AuthGuard({ export default function AuthGuard({
@ -13,6 +15,7 @@ export default function AuthGuard({
children: React.ReactNode; children: React.ReactNode;
minHeightClassName?: string; minHeightClassName?: string;
}) { }) {
const { locale } = useAppI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -86,7 +89,7 @@ export default function AuthGuard({
if (isAuthLoading) { if (isAuthLoading) {
return ( return (
<div className={`flex ${minHeightClassName} items-center justify-center`}> <div className={`flex ${minHeightClassName} items-center justify-center`}>
<div className="text-muted-foreground">...</div> <div className="text-muted-foreground">{pickAppText(locale, '加载中...', 'Loading...')}</div>
</div> </div>
); );
} }

View File

@ -4,37 +4,54 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, UserCircle2 } from 'lucide-react'; import { MessageSquare, Activity, Clock, Puzzle, Blocks, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, ChevronDown } from 'lucide-react';
import { logout } from '@/lib/api'; import { logout } from '@/lib/api';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { appConnectionStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
type NavItem = { type NavItem = {
name: string; key:
| 'chat'
| 'status'
| 'office'
| 'skills'
| 'plugins'
| 'agents'
| 'mcp'
| 'outlook'
| 'marketplace'
| 'files';
href: string; href: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
matchPrefixes?: string[]; matchPrefixes?: string[];
}; };
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ name: '对话', href: '/', icon: MessageSquare }, { key: 'chat', href: '/', icon: MessageSquare },
{ name: '状态', href: '/status', icon: Activity }, { key: 'status', href: '/status', icon: Activity },
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] }, { key: 'office', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
{ name: '技能', href: '/skills', icon: Puzzle }, { key: 'skills', href: '/skills', icon: Puzzle },
{ name: '插件', href: '/plugins', icon: Blocks }, { key: 'plugins', href: '/plugins', icon: Blocks },
{ name: '智能体', href: '/agents', icon: Bot }, { key: 'agents', href: '/agents', icon: Bot },
{ name: 'MCP', href: '/mcp', icon: ServerCog }, { key: 'mcp', href: '/mcp', icon: ServerCog },
{ name: 'Outlook', href: '/outlook', icon: Mail }, { key: 'outlook', href: '/outlook', icon: Mail },
{ name: '市场', href: '/marketplace', icon: Store }, { key: 'marketplace', href: '/marketplace', icon: Store },
{ name: '文件', href: '/files', icon: FolderOpen }, { key: 'files', href: '/files', icon: FolderOpen },
{ name: '帮助', href: '/help', icon: HelpCircle },
]; ];
const AUTH_ITEMS = [ const AUTH_ITEMS = [
{ name: '登录', href: '/login', icon: LogIn }, { key: 'login', href: '/login', icon: LogIn },
{ name: '注册', href: '/register', icon: UserPlus }, { key: 'register', href: '/register', icon: UserPlus },
]; ] as const;
function ConnectionDot() { function ConnectionDot() {
const { locale } = useAppI18n();
const wsStatus = useChatStore((s) => s.wsStatus); const wsStatus = useChatStore((s) => s.wsStatus);
const nanobotReady = useChatStore((s) => s.nanobotReady); const nanobotReady = useChatStore((s) => s.nanobotReady);
@ -49,15 +66,7 @@ function ConnectionDot() {
? 'bg-yellow-500' ? 'bg-yellow-500'
: 'bg-red-500'; : 'bg-red-500';
const label = isOnline const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale);
? '已连接'
: isChecking
? '检查中'
: wsStatus === 'connecting'
? '连接中'
: isOffline && wsStatus === 'connected'
? '服务离线'
: '未连接';
return ( return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@ -68,12 +77,32 @@ function ConnectionDot() {
} }
const Header = () => { const Header = () => {
const { locale } = useAppI18n();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const user = useChatStore((s) => s.user); const user = useChatStore((s) => s.user);
const isAuthLoading = useChatStore((s) => s.isAuthLoading); const isAuthLoading = useChatStore((s) => s.isAuthLoading);
const setUser = useChatStore((s) => s.setUser); const setUser = useChatStore((s) => s.setUser);
const navLabel = React.useCallback((key: NavItem['key']) => {
if (key === 'chat') return pickAppText(locale, '对话', 'Chat');
if (key === 'status') return pickAppText(locale, '状态', 'Status');
if (key === 'office') return pickAppText(locale, '任务管理', 'Tasks');
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins');
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
if (key === 'mcp') return 'MCP';
if (key === 'outlook') return 'Outlook';
if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace');
return pickAppText(locale, '文件', 'Files');
}, [locale]);
const authLabel = React.useCallback((key: 'login' | 'register') => (
key === 'login'
? pickAppText(locale, '登录', 'Sign In')
: pickAppText(locale, '注册', 'Sign Up')
), [locale]);
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
setUser(null); setUser(null);
@ -81,6 +110,8 @@ const Header = () => {
router.refresh(); router.refresh();
}; };
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
return ( return (
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50"> <header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
<div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10"> <div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10">
@ -117,28 +148,68 @@ const Header = () => {
}`} }`}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
{item.name} {navLabel(item.key)}
</Link> </Link>
); );
})} })}
</nav> </nav>
<div className="flex shrink-0 items-center gap-1.5 border-l border-border pl-4"> <div className="flex shrink-0 items-center gap-2 border-l border-border pl-4">
<LanguageSwitcher />
{user ? ( {user ? (
<> <Popover>
<div className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-foreground"> <PopoverTrigger asChild>
<UserCircle2 className="w-4 h-4" /> <button
<span className="max-w-32 truncate">{user.username}</span> type="button"
</div> className="flex items-center gap-2 rounded-full border border-border/70 bg-background px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
<button >
type="button" <Avatar className="h-8 w-8 border border-border/60">
onClick={handleLogout} <AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" {userInitial}
> </AvatarFallback>
<LogOut className="w-4 h-4" /> </Avatar>
退 <span className="hidden max-w-28 truncate sm:block">{user.username}</span>
</button> <ChevronDown className="h-4 w-4 text-muted-foreground" />
</> </button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
<div className="overflow-hidden rounded-3xl bg-gradient-to-b from-slate-50 via-slate-50 to-white">
<div className="border-b border-border/60 px-6 py-5">
<p className="truncate text-center text-sm font-medium text-muted-foreground">
{user.email}
</p>
</div>
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight text-foreground">
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
</p>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
</p>
</div>
</div>
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
<Button
type="button"
variant="outline"
onClick={handleLogout}
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
>
<LogOut className="mr-2 h-4 w-4" />
{pickAppText(locale, '退出登录', 'Sign Out')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
) : !isAuthLoading ? ( ) : !isAuthLoading ? (
AUTH_ITEMS.map((item) => { AUTH_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href); const isActive = pathname.startsWith(item.href);
@ -154,7 +225,7 @@ const Header = () => {
}`} }`}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
{item.name} {authLabel(item.key)}
</Link> </Link>
); );
}) })

View File

@ -0,0 +1,41 @@
'use client';
import { Languages } from 'lucide-react';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils';
const OPTIONS = [
{ value: 'zh-CN', label: 'ZH' },
{ value: 'en-US', label: 'EN' },
] as const;
export function LanguageSwitcher({ className }: { className?: string }) {
const { locale, setLocale } = useAppI18n();
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 p-1',
className
)}
>
<Languages className="h-3.5 w-3.5 text-muted-foreground" />
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLocale(option.value)}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
locale === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{option.label}
</button>
))}
</div>
);
}

View File

@ -6,6 +6,9 @@ import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; import type { ProcessArtifact, ProcessEvent, ProcessRun } 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 { appArtifactPreview, appFeedRoleLabel, appStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type RunCardPhase = 'live' | 'exiting' | 'collapsed'; type RunCardPhase = 'live' | 'exiting' | 'collapsed';
@ -51,15 +54,6 @@ function accentFor(index: number) {
return AGENT_ACCENTS[index % AGENT_ACCENTS.length]; 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']) { function statusTone(status: ProcessRun['status']) {
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300'; 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 === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
@ -69,13 +63,6 @@ function statusTone(status: ProcessRun['status']) {
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']) { function feedTone(role: AgentFeedItem['role']) {
if (role === 'user') { if (role === 'user') {
return 'ml-6 border-border/70 bg-muted/60 text-foreground'; return 'ml-6 border-border/70 bg-muted/60 text-foreground';
@ -89,22 +76,6 @@ function feedTone(role: AgentFeedItem['role']) {
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 { function delegatedTask(run: ProcessRun): string | null {
const value = run.metadata?.delegated_task; const value = run.metadata?.delegated_task;
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -114,6 +85,7 @@ function buildFeed(
run: ProcessRun, run: ProcessRun,
events: ProcessEvent[], events: ProcessEvent[],
artifacts: ProcessArtifact[], artifacts: ProcessArtifact[],
locale: 'zh-CN' | 'en-US',
): AgentFeedItem[] { ): AgentFeedItem[] {
const items: AgentFeedItem[] = []; const items: AgentFeedItem[] = [];
let hasLeadBubble = false; let hasLeadBubble = false;
@ -160,7 +132,7 @@ function buildFeed(
key: artifact.artifact_id, key: artifact.artifact_id,
created_at: artifact.created_at, created_at: artifact.created_at,
role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant', role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant',
text: artifactPreview(artifact), text: appArtifactPreview(artifact, locale),
}); });
} }
@ -181,12 +153,12 @@ function buildFeed(
.slice(-8); .slice(-8);
} }
function runSummary(run: ProcessRun, feed: AgentFeedItem[]): string { function runSummary(run: ProcessRun, feed: AgentFeedItem[], locale: 'zh-CN' | 'en-US'): string {
if (run.summary?.trim()) { if (run.summary?.trim()) {
return run.summary.trim(); return run.summary.trim();
} }
const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool'); const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool');
return latestAssistant?.text || '已完成子任务处理'; return latestAssistant?.text || pickAppText(locale, '已完成子任务处理', 'Subtask processing completed');
} }
function useRunCardPhases(runs: ProcessRun[]) { function useRunCardPhases(runs: ProcessRun[]) {
@ -256,7 +228,13 @@ function useRunCardPhases(runs: ProcessRun[]) {
return phases; return phases;
} }
function AgentBubble({ item }: { item: AgentFeedItem }) { function AgentBubble({
item,
locale,
}: {
item: AgentFeedItem;
locale: 'zh-CN' | 'en-US';
}) {
return ( return (
<div <div
className={cn( className={cn(
@ -266,7 +244,7 @@ function AgentBubble({ item }: { item: AgentFeedItem }) {
)} )}
> >
<div className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> <div className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<span>{roleLabel(item.role)}</span> <span>{appFeedRoleLabel(item.role, locale)}</span>
</div> </div>
<div className="whitespace-pre-wrap break-words">{item.text}</div> <div className="whitespace-pre-wrap break-words">{item.text}</div>
</div> </div>
@ -281,6 +259,7 @@ function LiveAgentCard({
phase, phase,
accentIndex, accentIndex,
onSelect, onSelect,
locale,
}: { }: {
run: ProcessRun; run: ProcessRun;
feed: AgentFeedItem[]; feed: AgentFeedItem[];
@ -289,6 +268,7 @@ function LiveAgentCard({
phase: RunCardPhase; phase: RunCardPhase;
accentIndex: number; accentIndex: number;
onSelect: () => void; onSelect: () => void;
locale: 'zh-CN' | 'en-US';
}) { }) {
const showSpinner = !TERMINAL_STATUSES.has(run.status); const showSpinner = !TERMINAL_STATUSES.has(run.status);
const accent = accentFor(accentIndex); const accent = accentFor(accentIndex);
@ -308,13 +288,13 @@ function LiveAgentCard({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground"> <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 className={cn('h-2 w-2 rounded-full', accent.dot)} />
<span>Sub-Agent</span> <span>{pickAppText(locale, '子 Agent', 'Sub-agent')}</span>
</div> </div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</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 className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
</div> </div>
<Badge variant="outline" className={cn('border', statusTone(run.status))}> <Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)} {appStatusLabel(run.status, locale)}
</Badge> </Badge>
</div> </div>
@ -322,11 +302,11 @@ function LiveAgentCard({
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1"> <div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
{feed.length === 0 && ( {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"> <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 ... {pickAppText(locale, '等待子 agent 输出...', 'Waiting for sub-agent output...')}
</div> </div>
)} )}
{feed.map((item) => ( {feed.map((item) => (
<AgentBubble key={item.key} item={item} /> <AgentBubble key={item.key} item={item} locale={locale} />
))} ))}
</div> </div>
</div> </div>
@ -335,10 +315,10 @@ function LiveAgentCard({
{showSpinner && ( {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"> <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" /> <Loader2 className="h-3 w-3 animate-spin" />
{pickAppText(locale, '运行中', 'Running')}
</span> </span>
)} )}
{artifactCount > 0 && <span>{artifactCount} </span>} {artifactCount > 0 && <span>{pickAppText(locale, `${artifactCount} 个输出`, `${artifactCount} outputs`)}</span>}
{typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>} {typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>}
</div> </div>
</button> </button>
@ -352,6 +332,7 @@ function ResultCard({
selected, selected,
accentIndex, accentIndex,
onSelect, onSelect,
locale,
}: { }: {
run: ProcessRun; run: ProcessRun;
summary: string; summary: string;
@ -359,6 +340,7 @@ function ResultCard({
selected: boolean; selected: boolean;
accentIndex: number; accentIndex: number;
onSelect: () => void; onSelect: () => void;
locale: 'zh-CN' | 'en-US';
}) { }) {
const accent = accentFor(accentIndex); const accent = accentFor(accentIndex);
@ -374,7 +356,7 @@ function ResultCard({
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Result</div> <div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">{pickAppText(locale, '结果', 'Result')}</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div> <div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
</div> </div>
<CheckCircle2 className="h-4 w-4 text-emerald-400" /> <CheckCircle2 className="h-4 w-4 text-emerald-400" />
@ -382,9 +364,9 @@ function ResultCard({
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</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"> <div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className={cn('border', statusTone(run.status))}> <Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)} {appStatusLabel(run.status, locale)}
</Badge> </Badge>
{artifactCount > 0 && <span>{artifactCount} </span>} {artifactCount > 0 && <span>{pickAppText(locale, `${artifactCount} 个输出`, `${artifactCount} outputs`)}</span>}
</div> </div>
</button> </button>
); );
@ -407,6 +389,7 @@ export function AgentTeamBlock({
onSelectRun: (runId: string) => void; onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void; onCancelRun: (runId: string) => void;
}) { }) {
const { locale } = useAppI18n();
const phases = useRunCardPhases(memberRuns); const phases = useRunCardPhases(memberRuns);
const sortedRuns = React.useMemo( const sortedRuns = React.useMemo(
() => () =>
@ -431,23 +414,24 @@ export function AgentTeamBlock({
<div className="mr-1 flex min-h-[68px] min-w-[132px] max-w-[180px] flex-col justify-center"> <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"> <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" /> <Sparkles className="h-3.5 w-3.5" />
Agent Results {pickAppText(locale, '智能体结果', 'Agent results')}
</div> </div>
<div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div> <div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div>
</div> </div>
{terminalRuns.map((run, index) => { {terminalRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id); const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id); const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts); const feed = buildFeed(run, runEvents, runArtifacts, locale);
return ( return (
<ResultCard <ResultCard
key={run.run_id} key={run.run_id}
run={run} run={run}
summary={runSummary(run, feed)} summary={runSummary(run, feed, locale)}
artifactCount={runArtifacts.length} artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id} selected={selectedRunId === run.run_id}
accentIndex={index} accentIndex={index}
onSelect={() => onSelectRun(run.run_id)} onSelect={() => onSelectRun(run.run_id)}
locale={locale}
/> />
); );
})} })}
@ -461,25 +445,27 @@ export function AgentTeamBlock({
<div> <div>
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground"> <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" /> <Sparkles className="h-3.5 w-3.5" />
Agent Team {pickAppText(locale, '智能体团队', 'Agent team')}
</div> </div>
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div> <div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{liveCount > 0 ? `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent` : '子 agent 已完成,结果已折叠为摘要卡片'} {liveCount > 0
? pickAppText(locale, `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent`, `Lead agent is coordinating ${liveCount} running sub-agents`)
: pickAppText(locale, '子 agent 已完成,结果已折叠为摘要卡片', 'Sub-agents are done. Results are folded into summary cards')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canCancelRoot && ( {canCancelRoot && (
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}> <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" /> <Square className="mr-1.5 h-3.5 w-3.5" />
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
)} )}
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85"> <Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
{memberRuns.length} sub-agent {pickAppText(locale, `${memberRuns.length} 个 sub-agent`, `${memberRuns.length} sub-agents`)}
</Badge> </Badge>
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}> <Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
{statusLabel(rootRun.status)} {appStatusLabel(rootRun.status, locale)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -490,7 +476,7 @@ export function AgentTeamBlock({
{liveRuns.map((run, index) => { {liveRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id); const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id); const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts); const feed = buildFeed(run, runEvents, runArtifacts, locale);
return ( return (
<LiveAgentCard <LiveAgentCard
key={run.run_id} key={run.run_id}
@ -501,6 +487,7 @@ export function AgentTeamBlock({
phase={phases[run.run_id] || 'live'} phase={phases[run.run_id] || 'live'}
accentIndex={index} accentIndex={index}
onSelect={() => onSelectRun(run.run_id)} onSelect={() => onSelectRun(run.run_id)}
locale={locale}
/> />
); );
})} })}
@ -513,16 +500,17 @@ export function AgentTeamBlock({
{collapsedRuns.map((run, index) => { {collapsedRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id); const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id); const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts); const feed = buildFeed(run, runEvents, runArtifacts, locale);
return ( return (
<ResultCard <ResultCard
key={run.run_id} key={run.run_id}
run={run} run={run}
summary={runSummary(run, feed)} summary={runSummary(run, feed, locale)}
artifactCount={runArtifacts.length} artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id} selected={selectedRunId === run.run_id}
accentIndex={index} accentIndex={index}
onSelect={() => onSelectRun(run.run_id)} onSelect={() => onSelectRun(run.run_id)}
locale={locale}
/> />
); );
})} })}

View File

@ -7,33 +7,9 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { appActorTypeLabel, appEventKindLabel, appStatusLabel } from '@/lib/i18n/common';
function statusLabel(status: string) { import { pickAppText } from '@/lib/i18n/core';
if (status === 'done') return '已完成'; import { useAppI18n } from '@/lib/i18n/provider';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'running') return '运行中';
if (status === 'queued') return '排队中';
return status;
}
function actorTypeLabel(actorType: string) {
if (actorType === 'mcp') return 'MCP';
if (actorType === 'system') return '系统';
if (actorType === 'agent') return '智能体';
return actorType;
}
function eventKindLabel(kind: string) {
if (kind === 'run_started') return '已启动';
if (kind === 'run_progress') return '进行中';
if (kind === 'run_status') return '状态更新';
if (kind === 'run_artifact') return '产物';
if (kind === 'run_finished') return '已结束';
if (kind === 'run_cancelled') return '已取消';
return kind;
}
function artifactIcon(type: ProcessArtifact['artifact_type']) { function artifactIcon(type: ProcessArtifact['artifact_type']) {
if (type === 'json') return <FileJson className="w-4 h-4" />; if (type === 'json') return <FileJson className="w-4 h-4" />;
@ -42,7 +18,7 @@ function artifactIcon(type: ProcessArtifact['artifact_type']) {
return <FileOutput className="w-4 h-4" />; return <FileOutput className="w-4 h-4" />;
} }
function renderArtifactBody(artifact: ProcessArtifact) { function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US') {
if (artifact.artifact_type === 'json' && artifact.data !== undefined) { if (artifact.artifact_type === 'json' && artifact.data !== undefined) {
return ( return (
<pre className="text-[11px] leading-5 whitespace-pre-wrap break-words rounded-md bg-background/70 p-3 overflow-x-auto"> <pre className="text-[11px] leading-5 whitespace-pre-wrap break-words rounded-md bg-background/70 p-3 overflow-x-auto">
@ -59,7 +35,7 @@ function renderArtifactBody(artifact: ProcessArtifact) {
} }
return ( return (
<div className="text-xs text-foreground/90 whitespace-pre-wrap break-words"> <div className="text-xs text-foreground/90 whitespace-pre-wrap break-words">
{artifact.content || '(空产物)'} {artifact.content || pickAppText(locale, '(空产物)', '(Empty artifact)')}
</div> </div>
); );
} }
@ -73,6 +49,7 @@ export function ArtifactSidebar({
events: ProcessEvent[]; events: ProcessEvent[];
artifacts: ProcessArtifact[]; artifacts: ProcessArtifact[];
}) { }) {
const { locale } = useAppI18n();
const runArtifacts = selectedRun const runArtifacts = selectedRun
? artifacts.filter((item) => item.run_id === selectedRun.run_id) ? artifacts.filter((item) => item.run_id === selectedRun.run_id)
: artifacts; : artifacts;
@ -90,9 +67,11 @@ export function ArtifactSidebar({
return ( return (
<div className="h-full bg-card/60 flex flex-col border-l border-border"> <div className="h-full bg-card/60 flex flex-col border-l border-border">
<div className="px-4 py-3 border-b border-border"> <div className="px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground"></h2> <h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">{pickAppText(locale, '结果面板', 'Results')}</h2>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{selectedRun ? `当前选中: ${selectedRun.actor_name}` : '选择一个任务查看详细过程与产物'} {selectedRun
? pickAppText(locale, `当前选中: ${selectedRun.actor_name}`, `Selected: ${selectedRun.actor_name}`)
: pickAppText(locale, '选择一个任务查看详细过程与产物', 'Select a task to inspect its process and artifacts')}
</p> </p>
</div> </div>
<ScrollArea className="flex-1 px-4 py-4"> <ScrollArea className="flex-1 px-4 py-4">
@ -101,24 +80,24 @@ export function ArtifactSidebar({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<FolderSearch className="w-4 h-4" /> <FolderSearch className="w-4 h-4" />
{pickAppText(locale, '任务摘要', 'Task summary')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-2 text-sm"> <CardContent className="pt-0 space-y-2 text-sm">
{selectedRun ? ( {selectedRun ? (
<> <>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">{actorTypeLabel(selectedRun.actor_type)}</Badge> <Badge variant="outline">{appActorTypeLabel(selectedRun.actor_type, locale)}</Badge>
<Badge variant="outline">{statusLabel(selectedRun.status)}</Badge> <Badge variant="outline">{appStatusLabel(selectedRun.status, locale)}</Badge>
{selectedRun.source && <Badge variant="secondary">{selectedRun.source}</Badge>} {selectedRun.source && <Badge variant="secondary">{selectedRun.source}</Badge>}
</div> </div>
<div className="font-medium">{selectedRun.title}</div> <div className="font-medium">{selectedRun.title}</div>
<div className="text-muted-foreground whitespace-pre-wrap break-words"> <div className="text-muted-foreground whitespace-pre-wrap break-words">
{selectedRun.summary || '暂时还没有最终摘要。'} {selectedRun.summary || pickAppText(locale, '暂时还没有最终摘要。', 'No final summary yet.')}
</div> </div>
</> </>
) : ( ) : (
<div className="text-muted-foreground text-sm"></div> <div className="text-muted-foreground text-sm">{pickAppText(locale, '当前没有选中的任务。', 'No task is selected right now.')}</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -127,22 +106,22 @@ export function ArtifactSidebar({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<MessagesSquare className="w-4 h-4" /> <MessagesSquare className="w-4 h-4" />
{pickAppText(locale, '事件记录', 'Events')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-2"> <CardContent className="pt-0 space-y-2">
{runEvents.length === 0 && ( {runEvents.length === 0 && (
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{pickAppText(locale, '暂时还没有结构化事件。', 'No structured events yet.')}</div>
)} )}
{runEvents.map((event, index) => ( {runEvents.map((event, index) => (
<div key={event.event_id}> <div key={event.event_id}>
<div className="rounded-md border border-border/60 px-3 py-2 bg-background/60"> <div className="rounded-md border border-border/60 px-3 py-2 bg-background/60">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
<span>{eventKindLabel(event.kind)}</span> <span>{appEventKindLabel(event.kind, locale)}</span>
{event.status && <span>{statusLabel(event.status)}</span>} {event.status && <span>{appStatusLabel(event.status, locale)}</span>}
</div> </div>
<div className="text-xs whitespace-pre-wrap break-words"> <div className="text-xs whitespace-pre-wrap break-words">
{event.text || '结构化更新'} {event.text || pickAppText(locale, '结构化更新', 'Structured update')}
</div> </div>
</div> </div>
{index < runEvents.length - 1 && <Separator className="my-2" />} {index < runEvents.length - 1 && <Separator className="my-2" />}
@ -155,12 +134,12 @@ export function ArtifactSidebar({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<FileOutput className="w-4 h-4" /> <FileOutput className="w-4 h-4" />
{pickAppText(locale, '产物列表', 'Artifacts')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-3"> <CardContent className="pt-0 space-y-3">
{runArtifacts.length === 0 && ( {runArtifacts.length === 0 && (
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{pickAppText(locale, '暂时还没有产物。', 'No artifacts yet.')}</div>
)} )}
{runArtifacts.map((artifact) => ( {runArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/70 bg-background/70 p-3 space-y-2"> <div key={artifact.artifact_id} className="rounded-lg border border-border/70 bg-background/70 p-3 space-y-2">
@ -175,7 +154,7 @@ export function ArtifactSidebar({
</div> </div>
</div> </div>
</div> </div>
{renderArtifactBody(artifact)} {renderArtifactBody(artifact, locale)}
</div> </div>
))} ))}
</CardContent> </CardContent>

View File

@ -6,6 +6,8 @@ import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/t
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 { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar'; import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export function ChatWorkbench({ export function ChatWorkbench({
messages, messages,
@ -30,6 +32,29 @@ export function ChatWorkbench({
onSelectRun: (runId: string) => void; onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void; onCancelRun: (runId: string) => void;
}) { }) {
const { locale } = useAppI18n();
const [isDesktop, setIsDesktop] = React.useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 1024px)').matches
);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(min-width: 1024px)');
const updateLayout = () => setIsDesktop(mediaQuery.matches);
updateLayout();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
const selectedRun = selectedRunId const selectedRun = selectedRunId
? processRuns.find((item) => item.run_id === selectedRunId) || null ? processRuns.find((item) => item.run_id === selectedRunId) || null
: null; : null;
@ -48,25 +73,29 @@ export function ChatWorkbench({
) )
); );
const desktopColumns = hasResultsPanel const desktopColumns = hasResultsPanel
? 'lg:grid-cols-[minmax(0,1fr)_360px]' ? 'grid-cols-[minmax(0,1fr)_360px]'
: 'lg:grid-cols-[minmax(0,1fr)]'; : 'grid-cols-[minmax(0,1fr)]';
return ( const messageList = (
<> <MessageList
<div className={`hidden lg:grid h-full ${desktopColumns}`}> messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
);
if (isDesktop) {
return (
<div className={`grid h-full ${desktopColumns}`}>
<div className="min-h-0"> <div className="min-h-0">
<MessageList {messageList}
messages={messages}
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div> </div>
{hasResultsPanel && ( {hasResultsPanel && (
<div className="min-h-0"> <div className="min-h-0">
@ -78,55 +107,33 @@ export function ChatWorkbench({
</div> </div>
)} )}
</div> </div>
);
}
<div className="lg:hidden h-full"> return (
{!hasResultsPanel ? ( <div className="h-full">
<MessageList {!hasResultsPanel ? (
messages={messages} messageList
isThinking={isThinking} ) : (
messagesEndRef={messagesEndRef} <Tabs defaultValue="chat" className="h-full flex flex-col">
viewportRef={messageViewportRef} <div className="px-4 pt-3 border-b border-border">
processRuns={processRuns} <TabsList className="grid w-full grid-cols-2">
processEvents={processEvents} <TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger>
processArtifacts={processArtifacts} <TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</TabsTrigger>
selectedRunId={selectedRun?.run_id || null} </TabsList>
onSelectRun={onSelectRun} </div>
onCancelRun={onCancelRun} <TabsContent value="chat" className="flex-1 min-h-0 mt-0">
/> {messageList}
) : ( </TabsContent>
<Tabs defaultValue="chat" className="h-full flex flex-col"> <TabsContent value="results" className="flex-1 min-h-0 mt-0">
<div className="px-4 pt-3 border-b border-border"> <ArtifactSidebar
<TabsList className="grid w-full grid-cols-2"> selectedRun={selectedRun}
<TabsTrigger value="chat"></TabsTrigger> events={processEvents}
{hasResultsPanel && <TabsTrigger value="results"></TabsTrigger>} artifacts={processArtifacts}
</TabsList> />
</div> </TabsContent>
<TabsContent value="chat" className="flex-1 min-h-0 mt-0"> </Tabs>
<MessageList )}
messages={messages} </div>
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</TabsContent>
{hasResultsPanel && (
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar
selectedRun={selectedRun}
events={processEvents}
artifacts={processArtifacts}
/>
</TabsContent>
)}
</Tabs>
)}
</div>
</>
); );
} }

View File

@ -8,6 +8,8 @@ import { getAccessToken, getFileUrl } from '@/lib/api';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock'; 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';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) { function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null); const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
@ -115,6 +117,20 @@ type AgentTeamGroup = {
startedAt: string; startedAt: string;
}; };
const TERMINAL_RUN_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
if (message.role !== 'assistant' || typeof message.content !== 'string') {
return false;
}
const content = message.content.trim();
return (
/^\[(Agent team|Subagent)\s+['"][^'"]+['"]\s+(completed|failed|cancelled|finished)\]/i.test(content)
|| (content.startsWith('[Agent team ') && content.includes('\nTask:'))
);
}
function parseTimelineTime(value?: string | null): number | null { function parseTimelineTime(value?: string | null): number | null {
if (!value) return null; if (!value) return null;
const parsed = new Date(value).getTime(); const parsed = new Date(value).getTime();
@ -194,9 +210,20 @@ export function MessageList({
onSelectRun: (runId: string) => void; onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void; onCancelRun: (runId: string) => void;
}) { }) {
const teamGroups = React.useMemo(() => buildAgentTeamGroups(processRuns), [processRuns]); const { locale } = useAppI18n();
const visibleMessages = React.useMemo(
() => messages.filter((message) => !shouldHideSystemAgentMessage(message)),
[messages]
);
const teamGroups = React.useMemo(
() =>
buildAgentTeamGroups(processRuns).filter((group) =>
group.memberRuns.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))
),
[processRuns]
);
const timelineItems = React.useMemo(() => { const timelineItems = React.useMemo(() => {
const messageItems = messages.map((message, index) => ({ const messageItems = visibleMessages.map((message, index) => ({
kind: 'message' as const, kind: 'message' as const,
key: `${message.role}:${message.timestamp || index}:${index}`, key: `${message.role}:${message.timestamp || index}:${index}`,
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index, sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
@ -204,12 +231,12 @@ export function MessageList({
message, message,
})); }));
const teamItems = teamGroups.map((group, index) => ({ const teamItems = teamGroups.map((group, index) => ({
kind: 'team' as const, kind: 'team' as const,
key: `team:${group.rootRun.run_id}`, key: `team:${group.rootRun.run_id}`,
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index, sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + visibleMessages.length + index,
order: messages.length + index, order: visibleMessages.length + index,
group, group,
})); }));
return [...messageItems, ...teamItems].sort((a, b) => { return [...messageItems, ...teamItems].sort((a, b) => {
if (a.sortTime !== b.sortTime) { if (a.sortTime !== b.sortTime) {
@ -217,16 +244,16 @@ export function MessageList({
} }
return a.order - b.order; return a.order - b.order;
}); });
}, [messages, teamGroups]); }, [teamGroups, visibleMessages]);
return ( return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}> <ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-6xl mx-auto py-4 space-y-4"> <div className="max-w-6xl mx-auto py-4 space-y-4">
{messages.length === 0 && teamGroups.length === 0 && !isThinking && ( {visibleMessages.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>
<p className="text-sm"></p> <p className="text-sm">{pickAppText(locale, '发送消息开始对话', 'Send a message to start the conversation')}</p>
</div> </div>
)} )}
@ -251,7 +278,7 @@ export function MessageList({
<div className="flex items-center gap-2 text-muted-foreground px-1"> <div className="flex items-center gap-2 text-muted-foreground px-1">
<Bot className="w-5 h-5" /> <Bot className="w-5 h-5" />
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span> <span className="text-sm">{pickAppText(locale, '思考中...', 'Thinking...')}</span>
</div> </div>
)} )}

View File

@ -7,35 +7,11 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { appActorTypeLabel, appEventKindLabel, appStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function statusLabel(status: string) {
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'running') return '运行中';
if (status === 'queued') return '排队中';
return status;
}
function actorTypeLabel(actorType: string) {
if (actorType === 'mcp') return 'MCP';
if (actorType === 'system') return '系统';
if (actorType === 'agent') return '智能体';
return actorType;
}
function eventKindLabel(kind: string) {
if (kind === 'run_started') return '已启动';
if (kind === 'run_progress') return '进行中';
if (kind === 'run_status') return '状态更新';
if (kind === 'run_artifact') return '产物';
if (kind === 'run_finished') return '已结束';
if (kind === 'run_cancelled') return '已取消';
return kind;
}
function statusTone(status: string) { function statusTone(status: string) {
if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20'; if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20';
if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20'; if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20';
@ -63,6 +39,7 @@ export function ProcessLane({
onSelectRun: (runId: string) => void; onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void; onCancelRun: (runId: string) => void;
}) { }) {
const { locale } = useAppI18n();
const sortedRuns = [...runs].sort((a, b) => { const sortedRuns = [...runs].sort((a, b) => {
const at = new Date(a.started_at).getTime(); const at = new Date(a.started_at).getTime();
const bt = new Date(b.started_at).getTime(); const bt = new Date(b.started_at).getTime();
@ -77,11 +54,11 @@ export function ProcessLane({
<div className="h-full flex flex-col bg-card/60 border-l border-border"> <div className="h-full flex flex-col bg-card/60 border-l border-border">
<div className="px-4 py-3 border-b border-border flex items-center justify-between"> <div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div> <div>
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground"></h2> <h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">{pickAppText(locale, '执行过程', 'Execution')}</h2>
<p className="text-xs text-muted-foreground mt-1">A2AMCP </p> <p className="text-xs text-muted-foreground mt-1">{pickAppText(locale, '智能体、A2A、MCP 的实时过程', 'Live process stream for agents, A2A, and MCP')}</p>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{sortedRuns.length} {pickAppText(locale, `${sortedRuns.length} 个任务`, `${sortedRuns.length} tasks`)}
</Badge> </Badge>
</div> </div>
<ScrollArea className="flex-1 px-4 py-4"> <ScrollArea className="flex-1 px-4 py-4">
@ -120,7 +97,7 @@ export function ProcessLane({
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className={cn('text-[10px] border', statusTone(run.status))}> <Badge variant="outline" className={cn('text-[10px] border', statusTone(run.status))}>
{statusLabel(run.status)} {appStatusLabel(run.status, locale)}
</Badge> </Badge>
{canCancel && ( {canCancel && (
<Button <Button
@ -133,7 +110,7 @@ export function ProcessLane({
}} }}
> >
<Square className="w-3.5 h-3.5 mr-1" /> <Square className="w-3.5 h-3.5 mr-1" />
{pickAppText(locale, '取消', 'Cancel')}
</Button> </Button>
)} )}
</div> </div>
@ -141,9 +118,9 @@ export function ProcessLane({
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-2"> <CardContent className="pt-0 space-y-2">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap"> <div className="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>{actorTypeLabel(run.actor_type)}</span> <span>{appActorTypeLabel(run.actor_type, locale)}</span>
{run.source && <span>{run.source}</span>} {run.source && <span>{run.source}</span>}
{run.parent_run_id && <span></span>} {run.parent_run_id && <span>{pickAppText(locale, '子任务', 'Subtask')}</span>}
</div> </div>
{run.summary && ( {run.summary && (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3"> <div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
@ -154,24 +131,24 @@ export function ProcessLane({
{runEvents.length === 0 && run.status === 'running' && ( {runEvents.length === 0 && run.status === 'running' && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
... {pickAppText(locale, '等待首个事件...', 'Waiting for the first event...')}
</div> </div>
)} )}
{runEvents.map((event) => ( {runEvents.map((event) => (
<div key={event.event_id} className="text-xs rounded-md border border-border/50 bg-background/60 px-3 py-2"> <div key={event.event_id} className="text-xs rounded-md border border-border/50 bg-background/60 px-3 py-2">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
<span>{eventKindLabel(event.kind)}</span> <span>{appEventKindLabel(event.kind, locale)}</span>
{event.status && <span>{statusLabel(event.status)}</span>} {event.status && <span>{appStatusLabel(event.status, locale)}</span>}
</div> </div>
<div className="text-foreground/90 whitespace-pre-wrap break-words"> <div className="text-foreground/90 whitespace-pre-wrap break-words">
{event.text || '结构化更新'} {event.text || pickAppText(locale, '结构化更新', 'Structured update')}
</div> </div>
</div> </div>
))} ))}
{run.status === 'error' && ( {run.status === 'error' && (
<div className="flex items-center gap-2 text-xs text-rose-300"> <div className="flex items-center gap-2 text-xs text-rose-300">
<AlertCircle className="w-3.5 h-3.5" /> <AlertCircle className="w-3.5 h-3.5" />
{pickAppText(locale, '此任务执行失败。', 'This task failed.')}
</div> </div>
)} )}
</div> </div>

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { useAppI18n } from '@/lib/i18n/provider';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office'; import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office';
@ -12,6 +13,8 @@ export function OfficeStatusBadge({
status: OfficeTaskStatus; status: OfficeTaskStatus;
className?: string; className?: string;
}) { }) {
const { locale } = useAppI18n();
return ( return (
<Badge <Badge
variant="outline" variant="outline"
@ -27,16 +30,16 @@ export function OfficeStatusBadge({
className className
)} )}
> >
{officeTaskStatusLabel(status)} {officeTaskStatusLabel(status, locale)}
</Badge> </Badge>
); );
} }
export function formatOfficeTime(value?: string | null): string { export function formatOfficeTime(value?: string | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale, {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
@ -44,18 +47,18 @@ export function formatOfficeTime(value?: string | null): string {
}).format(date); }).format(date);
} }
export function formatOfficeDuration(durationMs: number | null): string { export function formatOfficeDuration(durationMs: number | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
if (durationMs === null || durationMs < 0) return '-'; if (durationMs === null || durationMs < 0) return '-';
if (durationMs < 1000) return '<1s'; if (durationMs < 1000) return locale === 'en-US' ? '<1s' : '<1秒';
const seconds = Math.floor(durationMs / 1000); const seconds = Math.floor(durationMs / 1000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`; if (hours > 0) return locale === 'en-US' ? `${hours}h ${minutes}m` : `${hours}小时 ${minutes}`;
if (minutes > 0) return `${minutes}m ${remainingSeconds}s`; if (minutes > 0) return locale === 'en-US' ? `${minutes}m ${remainingSeconds}s` : `${minutes}${remainingSeconds}`;
return `${remainingSeconds}s`; return locale === 'en-US' ? `${remainingSeconds}s` : `${remainingSeconds}`;
} }
export function progressPercent(value: number | null, max: number | null): number { export function progressPercent(value: number | null, max: number | null): number {

View File

@ -4,6 +4,8 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Building2, Clock3 } from 'lucide-react'; import { Building2, Clock3 } from 'lucide-react';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const TASK_MANAGEMENT_TABS = [ const TASK_MANAGEMENT_TABS = [
@ -14,7 +16,7 @@ const TASK_MANAGEMENT_TABS = [
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'), match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
}, },
{ {
label: '定时任务', label: 'Scheduled tasks',
href: '/cron', href: '/cron',
icon: Clock3, icon: Clock3,
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'), match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
@ -22,6 +24,7 @@ const TASK_MANAGEMENT_TABS = [
] as const; ] as const;
export function TaskManagementTabs() { export function TaskManagementTabs() {
const { locale } = useAppI18n();
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@ -43,7 +46,9 @@ export function TaskManagementTabs() {
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{tab.label} {tab.href === '/cron'
? pickAppText(locale, '定时任务', 'Scheduled tasks')
: pickAppText(locale, '办公室', 'Office')}
</Link> </Link>
); );
})} })}

View File

@ -31,6 +31,7 @@ import type {
UiMcpServerDescriptor, UiMcpServerDescriptor,
WsEvent, WsEvent,
} from '@/types'; } from '@/types';
import { getCurrentAppLocale, pickAppText } from '@/lib/i18n/core';
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim(); const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim(); const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
@ -90,9 +91,10 @@ function withTimeout(
signal?: AbortSignal, signal?: AbortSignal,
timeoutMs: number = REQUEST_TIMEOUT_MS timeoutMs: number = REQUEST_TIMEOUT_MS
): { signal: AbortSignal; cleanup: () => void } { ): { signal: AbortSignal; cleanup: () => void } {
const locale = getCurrentAppLocale();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => { const timeoutId = globalThis.setTimeout(() => {
controller.abort(new DOMException('请求超时', 'AbortError')); controller.abort(new DOMException(pickAppText(locale, '请求超时', 'Request timed out'), 'AbortError'));
}, timeoutMs); }, timeoutMs);
const forwardAbort = () => controller.abort(signal?.reason); const forwardAbort = () => controller.abort(signal?.reason);
@ -154,6 +156,7 @@ function authHeaders(includeJsonContentType: boolean = true): Record<string, str
} }
async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T> { async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T> {
const locale = getCurrentAppLocale();
const mergedHeaders = { const mergedHeaders = {
...authHeaders(), ...authHeaders(),
...(options?.headers as Record<string, string> | undefined), ...(options?.headers as Record<string, string> | undefined),
@ -170,7 +173,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
} catch (error) { } catch (error) {
cleanup(); cleanup();
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('请求超时'); throw new Error(pickAppText(locale, '请求超时', 'Request timed out'));
} }
throw error; throw error;
} }
@ -191,7 +194,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
} catch { } catch {
// keep raw text // keep raw text
} }
throw new Error(`接口错误 ${res.status}: ${detail}`); throw new Error(`${pickAppText(locale, '接口错误', 'API error')} ${res.status}: ${detail}`);
} }
return res.json(); return res.json();
} }
@ -262,6 +265,7 @@ export function streamMessage(
onError: (error: string) => void onError: (error: string) => void
): () => void { ): () => void {
const controller = new AbortController(); const controller = new AbortController();
const locale = getCurrentAppLocale();
(async () => { (async () => {
try { try {
@ -273,7 +277,7 @@ export function streamMessage(
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
onError(`HTTP 错误 ${res.status}`); onError(`${pickAppText(locale, 'HTTP 错误', 'HTTP error')} ${res.status}`);
return; return;
} }
@ -308,7 +312,7 @@ export function streamMessage(
} }
} catch (err: any) { } catch (err: any) {
if (err.name !== 'AbortError') { if (err.name !== 'AbortError') {
onError(err.message || '流式请求失败'); onError(err.message || pickAppText(locale, '流式请求失败', 'Streaming request failed'));
} }
} }
})(); })();
@ -1059,8 +1063,9 @@ export async function uploadFile(
sessionId: string = 'web:default', sessionId: string = 'web:default',
onProgress?: (percent: number) => void onProgress?: (percent: number) => void
): Promise<FileAttachment> { ): Promise<FileAttachment> {
const locale = getCurrentAppLocale();
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
throw new Error('文件过大(最大 50MB'); throw new Error(pickAppText(locale, '文件过大(最大 50MB', 'File is too large (max 50MB)'));
} }
const formData = new FormData(); const formData = new FormData();
@ -1086,11 +1091,11 @@ export async function uploadFile(
const data = JSON.parse(xhr.responseText); const data = JSON.parse(xhr.responseText);
resolve(data); resolve(data);
} else { } else {
reject(new Error(`上传失败:${xhr.status}`)); reject(new Error(`${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
} }
}; };
xhr.onerror = () => reject(new Error('上传失败')); xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
xhr.send(formData); xhr.send(formData);
}); });
@ -1142,8 +1147,9 @@ export async function uploadToWorkspace(
dirPath: string = '', dirPath: string = '',
onProgress?: (percent: number) => void onProgress?: (percent: number) => void
): Promise<WorkspaceItem> { ): Promise<WorkspaceItem> {
const locale = getCurrentAppLocale();
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
throw new Error('文件过大(最大 50MB'); throw new Error(pickAppText(locale, '文件过大(最大 50MB', 'File is too large (max 50MB)'));
} }
const formData = new FormData(); const formData = new FormData();
@ -1168,11 +1174,11 @@ export async function uploadToWorkspace(
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText)); resolve(JSON.parse(xhr.responseText));
} else { } else {
reject(new Error(`上传失败:${xhr.status}`)); reject(new Error(`${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
} }
}; };
xhr.onerror = () => reject(new Error('上传失败')); xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
xhr.send(formData); xhr.send(formData);
}); });
} }

View File

@ -0,0 +1,78 @@
import type { OfficeTaskStatus } from '@/lib/office';
import type { ProcessArtifact, ProcessRun } from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
import type { WsStatus } from '@/lib/api';
export function appStatusLabel(
status: ProcessRun['status'] | OfficeTaskStatus | string,
locale: AppLocale = getCurrentAppLocale()
): string {
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
if (status === 'running') return pickAppText(locale, '运行中', 'Running');
if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
if (status === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
if (status === 'done') return pickAppText(locale, '已完成', 'Done');
if (status === 'error') return pickAppText(locale, '失败', 'Error');
if (status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
return status;
}
export function appActorTypeLabel(actorType: string, locale: AppLocale = getCurrentAppLocale()): string {
if (actorType === 'mcp') return 'MCP';
if (actorType === 'system') return pickAppText(locale, '系统', 'System');
if (actorType === 'agent') return pickAppText(locale, '智能体', 'Agent');
return actorType;
}
export function appEventKindLabel(kind: string, locale: AppLocale = getCurrentAppLocale()): string {
if (kind === 'run_started') return pickAppText(locale, '已启动', 'Started');
if (kind === 'run_progress') return pickAppText(locale, '进行中', 'In Progress');
if (kind === 'run_status') return pickAppText(locale, '状态更新', 'Status');
if (kind === 'run_message') return pickAppText(locale, '消息', 'Message');
if (kind === 'run_artifact') return pickAppText(locale, '产物', 'Artifact');
if (kind === 'run_finished') return pickAppText(locale, '已结束', 'Finished');
if (kind === 'run_cancelled') return pickAppText(locale, '已取消', 'Cancelled');
return kind;
}
export function appFeedRoleLabel(
role: 'user' | 'assistant' | 'system' | 'tool',
locale: AppLocale = getCurrentAppLocale()
): string {
if (role === 'user') return pickAppText(locale, '主 agent', 'Lead agent');
if (role === 'tool') return pickAppText(locale, '工具输出', 'Tool output');
if (role === 'system') return pickAppText(locale, '状态', 'Status');
return pickAppText(locale, '子 agent', 'Sub-agent');
}
export function appArtifactPreview(artifact: ProcessArtifact, locale: AppLocale = getCurrentAppLocale()): 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${pickAppText(locale, '已生成结构化结果', 'Structured output generated')}`;
}
if (artifact.file_id) {
return `${artifact.title}\n${pickAppText(locale, '已生成文件输出', 'File output generated')}`;
}
return artifact.title;
}
export function appConnectionStatusLabel(
wsStatus: WsStatus,
nanobotReady: boolean | null,
locale: AppLocale = getCurrentAppLocale()
): string {
const isOnline = wsStatus === 'connected' && nanobotReady === true;
const isChecking = wsStatus === 'connected' && nanobotReady === null;
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
if (isOnline) return pickAppText(locale, '已连接', 'Connected');
if (isChecking) return pickAppText(locale, '检查中', 'Checking');
if (wsStatus === 'connecting') return pickAppText(locale, '连接中', 'Connecting');
if (isOffline && wsStatus === 'connected') return pickAppText(locale, '服务离线', 'Service offline');
return pickAppText(locale, '未连接', 'Disconnected');
}

View File

@ -0,0 +1,76 @@
export const APP_LOCALE_COOKIE = 'nanobot_locale';
export const APP_LOCALE_STORAGE_KEY = 'nanobot_locale';
export const APP_LOCALES = ['zh-CN', 'en-US'] as const;
export type AppLocale = (typeof APP_LOCALES)[number];
export function isAppLocale(value: string | null | undefined): value is AppLocale {
return value === 'zh-CN' || value === 'en-US';
}
export function normalizeAppLocale(value?: string | null): AppLocale {
const probe = value?.trim().toLowerCase() || '';
if (probe.startsWith('en')) {
return 'en-US';
}
return 'zh-CN';
}
function readCookieLocale(): string | null {
if (typeof document === 'undefined') {
return null;
}
const match = document.cookie
.split('; ')
.find((item) => item.startsWith(`${APP_LOCALE_COOKIE}=`));
if (!match) {
return null;
}
return decodeURIComponent(match.slice(APP_LOCALE_COOKIE.length + 1));
}
export function readBrowserAppLocale(): AppLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
const fromDocument = document.documentElement.lang;
if (fromDocument) {
return normalizeAppLocale(fromDocument);
}
const fromStorage = window.localStorage.getItem(APP_LOCALE_STORAGE_KEY);
if (fromStorage) {
return normalizeAppLocale(fromStorage);
}
const fromCookie = readCookieLocale();
if (fromCookie) {
return normalizeAppLocale(fromCookie);
}
return normalizeAppLocale(window.navigator.language);
}
export function persistAppLocale(locale: AppLocale): void {
if (typeof window === 'undefined') {
return;
}
document.documentElement.lang = locale;
window.localStorage.setItem(APP_LOCALE_STORAGE_KEY, locale);
document.cookie = `${APP_LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
}
export function getCurrentAppLocale(): AppLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
return readBrowserAppLocale();
}
export function pickAppText<T>(locale: AppLocale, zhValue: T, enValue: T): T {
return locale === 'en-US' ? enValue : zhValue;
}

View File

@ -0,0 +1,57 @@
'use client';
import React from 'react';
import {
type AppLocale,
persistAppLocale,
readBrowserAppLocale,
} from '@/lib/i18n/core';
type AppI18nContextValue = {
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
};
const AppI18nContext = React.createContext<AppI18nContextValue | null>(null);
export function AppI18nProvider({
initialLocale,
children,
}: {
initialLocale: AppLocale;
children: React.ReactNode;
}) {
const [locale, setLocaleState] = React.useState<AppLocale>(initialLocale);
React.useEffect(() => {
const browserLocale = readBrowserAppLocale();
if (browserLocale !== locale) {
setLocaleState(browserLocale);
return;
}
persistAppLocale(locale);
}, []);
React.useEffect(() => {
persistAppLocale(locale);
}, [locale]);
const value = React.useMemo<AppI18nContextValue>(
() => ({
locale,
setLocale: setLocaleState,
}),
[locale]
);
return <AppI18nContext.Provider value={value}>{children}</AppI18nContext.Provider>;
}
export function useAppI18n(): AppI18nContextValue {
const value = React.useContext(AppI18nContext);
if (!value) {
throw new Error('useAppI18n must be used within AppI18nProvider');
}
return value;
}

View File

@ -0,0 +1,17 @@
import { cookies, headers } from 'next/headers';
import { APP_LOCALE_COOKIE, normalizeAppLocale, type AppLocale } from '@/lib/i18n/core';
export function getServerAppLocale(): AppLocale {
const cookieLocale = cookies().get(APP_LOCALE_COOKIE)?.value;
if (cookieLocale) {
return normalizeAppLocale(cookieLocale);
}
const acceptLanguage = headers().get('accept-language');
if (acceptLanguage) {
return normalizeAppLocale(acceptLanguage);
}
return 'zh-CN';
}

View File

@ -224,4 +224,40 @@ describe('office view builders', () => {
expect(tasks[1].taskId).toBe('run-done'); expect(tasks[1].taskId).toBe('run-done');
expect(tasks[0].sessionLabel).toBe('Alpha Session'); expect(tasks[0].sessionLabel).toBe('Alpha Session');
}); });
it('keeps office tasks visible when the root run inherits session from descendants', () => {
const tasks = buildOfficeTaskList({
sessionId: 'web:alpha',
sessions: [{ key: 'web:alpha', path: 'Alpha Session' }],
processRuns: [
{
run_id: 'run-root-no-session',
parent_run_id: null,
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-child-with-session',
parent_run_id: 'run-root-no-session',
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-b',
actor_name: 'Agent B',
title: '子任务仍带着会话字段',
status: 'running',
started_at: '2026-03-24T11:21:00.000Z',
},
],
processEvents: [],
processArtifacts: [],
});
expect(tasks).toHaveLength(1);
expect(tasks[0].taskId).toBe('run-root-no-session');
expect(tasks[0].sessionId).toBe('web:alpha');
});
}); });

View File

@ -6,6 +6,7 @@ import type {
ProcessRunStatus, ProcessRunStatus,
Session, Session,
} from '@/types'; } from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']); const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']);
const STALE_WAITING_MS = 2 * 60 * 1000; const STALE_WAITING_MS = 2 * 60 * 1000;
@ -200,8 +201,8 @@ function latestTimestamp(values: Array<string | null | undefined>): string | nul
return selected; return selected;
} }
function getSessionLabel(sessions: Session[], sessionId: string | null): string { function getSessionLabel(sessions: Session[], sessionId: string | null, locale: AppLocale): string {
if (!sessionId) return '未关联会话'; if (!sessionId) return pickAppText(locale, '未关联会话', 'No session linked');
const session = sessions.find((item) => item.key === sessionId); const session = sessions.find((item) => item.key === sessionId);
if (!session) return sessionId; if (!session) return sessionId;
return session.path?.trim() || session.key; return session.path?.trim() || session.key;
@ -279,6 +280,7 @@ function deriveStageLabel(
run: ProcessRun, run: ProcessRun,
runEvents: ProcessEvent[], runEvents: ProcessEvent[],
fallbackStatus: OfficeTaskStatus, fallbackStatus: OfficeTaskStatus,
locale: AppLocale,
): string | null { ): string | null {
const runMetadataLabel = readMetadataString(run.metadata, [ const runMetadataLabel = readMetadataString(run.metadata, [
'stage_label', 'stage_label',
@ -299,13 +301,13 @@ function deriveStageLabel(
if (label) return label; if (label) return label;
} }
if (fallbackStatus === 'running') return '执行中'; if (fallbackStatus === 'running') return pickAppText(locale, '执行中', 'Running');
if (fallbackStatus === 'waiting') return '等待中'; if (fallbackStatus === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
if (fallbackStatus === 'queued') return '排队中'; if (fallbackStatus === 'queued') return pickAppText(locale, '排队中', 'Queued');
if (fallbackStatus === 'done') return '已完成'; if (fallbackStatus === 'done') return pickAppText(locale, '已完成', 'Done');
if (fallbackStatus === 'error') return '失败'; if (fallbackStatus === 'error') return pickAppText(locale, '失败', 'Error');
if (fallbackStatus === 'cancelled') return '已取消'; if (fallbackStatus === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
if (fallbackStatus === 'blocked') return '阻塞'; if (fallbackStatus === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
return null; return null;
} }
@ -330,13 +332,13 @@ function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): Offic
return 'alert'; return 'alert';
} }
function zoneLabel(zoneId: OfficeZoneId): string { function zoneLabel(zoneId: OfficeZoneId, locale: AppLocale): string {
if (zoneId === 'reception') return '接待区'; if (zoneId === 'reception') return pickAppText(locale, '接待区', 'Reception');
if (zoneId === 'workspace') return '工位区'; if (zoneId === 'workspace') return pickAppText(locale, '工位区', 'Workspace');
if (zoneId === 'collab') return '协作区'; if (zoneId === 'collab') return pickAppText(locale, '协作区', 'Collaboration');
if (zoneId === 'research') return '研究区'; if (zoneId === 'research') return pickAppText(locale, '研究区', 'Research');
if (zoneId === 'alert') return '异常区'; if (zoneId === 'alert') return pickAppText(locale, '异常区', 'Alerts');
return '完成区'; return pickAppText(locale, '完成区', 'Completed');
} }
function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] { function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] {
@ -378,7 +380,7 @@ function selectDisplayRun(
return sorted[0]; return sorted[0];
} }
function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | null { function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[], locale: AppLocale): string | null {
if (run.status !== 'error') return null; if (run.status !== 'error') return null;
const direct = firstString(run.summary); const direct = firstString(run.summary);
if (direct) return direct; if (direct) return direct;
@ -388,13 +390,14 @@ function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | n
return event.text!.trim(); return event.text!.trim();
} }
} }
return '任务执行失败'; return pickAppText(locale, '任务执行失败', 'Task execution failed');
} }
function deriveProgress( function deriveProgress(
rootRun: ProcessRun, rootRun: ProcessRun,
taskRuns: ProcessRun[], taskRuns: ProcessRun[],
taskViews: OfficeTaskView[], taskViews: OfficeTaskView[],
locale: AppLocale,
): OfficeProgressView { ): OfficeProgressView {
const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']); const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']);
const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']); const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']);
@ -403,7 +406,11 @@ function deriveProgress(
if (stageValue !== null && stageMax !== null && stageMax > 0) { if (stageValue !== null && stageMax !== null && stageMax > 0) {
return { return {
mode: 'ratio', mode: 'ratio',
label: `阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`, label: pickAppText(
locale,
`阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
`Stage ${Math.min(stageValue, stageMax)} / ${stageMax}`
),
value: stageValue, value: stageValue,
max: stageMax, max: stageMax,
stageLabel, stageLabel,
@ -414,7 +421,11 @@ function deriveProgress(
if (taskRuns.length > 0) { if (taskRuns.length > 0) {
return { return {
mode: 'ratio', mode: 'ratio',
label: `已完成子任务 ${doneRuns} / ${taskRuns.length}`, label: pickAppText(
locale,
`已完成子任务 ${doneRuns} / ${taskRuns.length}`,
`Subtasks completed ${doneRuns} / ${taskRuns.length}`
),
value: doneRuns, value: doneRuns,
max: taskRuns.length, max: taskRuns.length,
stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null, stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null,
@ -423,7 +434,7 @@ function deriveProgress(
return { return {
mode: 'status', mode: 'status',
label: '等待任务数据', label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
value: null, value: null,
max: null, max: null,
stageLabel, stageLabel,
@ -433,6 +444,7 @@ function deriveProgress(
function buildAlerts( function buildAlerts(
taskViews: OfficeTaskView[], taskViews: OfficeTaskView[],
now: number, now: number,
locale: AppLocale,
): OfficeAlertView[] { ): OfficeAlertView[] {
const alerts: OfficeAlertView[] = []; const alerts: OfficeAlertView[] = [];
@ -441,7 +453,7 @@ function buildAlerts(
alerts.push({ alerts.push({
id: `error:${task.runId}`, id: `error:${task.runId}`,
level: 'error', level: 'error',
title: `${task.actorName} 执行失败`, title: pickAppText(locale, `${task.actorName} 执行失败`, `${task.actorName} failed`),
description: task.errorText, description: task.errorText,
runId: task.runId, runId: task.runId,
actorId: task.actorId, actorId: task.actorId,
@ -451,8 +463,8 @@ function buildAlerts(
alerts.push({ alerts.push({
id: `blocked:${task.runId}`, id: `blocked:${task.runId}`,
level: 'warn', level: 'warn',
title: `${task.actorName} 长时间等待`, title: pickAppText(locale, `${task.actorName} 长时间等待`, `${task.actorName} has been waiting for a while`),
description: '该任务长时间无更新,可能存在阻塞。', description: pickAppText(locale, '该任务长时间无更新,可能存在阻塞。', 'This task has not updated for a while and may be blocked.'),
runId: task.runId, runId: task.runId,
actorId: task.actorId, actorId: task.actorId,
createdAt: task.updatedAt, createdAt: task.updatedAt,
@ -463,8 +475,8 @@ function buildAlerts(
alerts.push({ alerts.push({
id: `stale:${task.runId}`, id: `stale:${task.runId}`,
level: 'warn', level: 'warn',
title: `${task.actorName} 等待时间偏长`, title: pickAppText(locale, `${task.actorName} 等待时间偏长`, `${task.actorName} has been waiting longer than expected`),
description: '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', description: pickAppText(locale, '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', 'This task is still waiting. Check the details to confirm whether a dependency is stuck.'),
runId: task.runId, runId: task.runId,
actorId: task.actorId, actorId: task.actorId,
createdAt: task.updatedAt, createdAt: task.updatedAt,
@ -476,18 +488,18 @@ function buildAlerts(
return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt)); return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt));
} }
function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[]): OfficeZoneView[] { function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[], locale: AppLocale): OfficeZoneView[] {
const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done']; const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done'];
return ids.map((id) => ({ return ids.map((id) => ({
id, id,
label: zoneLabel(id), label: zoneLabel(id, locale),
memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId), 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), taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId),
tone: zoneTone(id), tone: zoneTone(id),
})); }));
} }
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>): OfficeAssignmentView[] { function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>, locale: AppLocale): OfficeAssignmentView[] {
return taskRuns return taskRuns
.filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0) .filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0)
.map((run) => { .map((run) => {
@ -497,7 +509,7 @@ function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, Proce
ownerActorName: run.actor_name, ownerActorName: run.actor_name,
assigneeRunIds: children.map((item) => item.run_id), assigneeRunIds: children.map((item) => item.run_id),
assigneeActorNames: children.map((item) => item.actor_name), assigneeActorNames: children.map((item) => item.actor_name),
label: `${run.actor_name} 分派了 ${children.length} 个子任务`, label: pickAppText(locale, `${run.actor_name} 分派了 ${children.length} 个子任务`, `${run.actor_name} assigned ${children.length} subtasks`),
}; };
}); });
} }
@ -506,19 +518,20 @@ export function isOfficeTaskTerminal(status: OfficeTaskStatus): boolean {
return TERMINAL_STATUSES.has(status); return TERMINAL_STATUSES.has(status);
} }
export function officeTaskStatusLabel(status: OfficeTaskStatus): string { export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocale = getCurrentAppLocale()): string {
if (status === 'queued') return '排队中'; if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
if (status === 'running') return '进行中'; if (status === 'running') return pickAppText(locale, '进行中', 'In Progress');
if (status === 'waiting') return '等待中'; if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
if (status === 'blocked') return '阻塞'; if (status === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
if (status === 'done') return '已完成'; if (status === 'done') return pickAppText(locale, '已完成', 'Done');
if (status === 'error') return '失败'; if (status === 'error') return pickAppText(locale, '失败', 'Error');
return '已取消'; return pickAppText(locale, '已取消', 'Cancelled');
} }
export function buildOfficeView( export function buildOfficeView(
taskId: string, taskId: string,
input: BuildOfficeInput, input: BuildOfficeInput,
locale: AppLocale = getCurrentAppLocale(),
): OfficeView | null { ): OfficeView | null {
const { sessions, processRuns, processEvents, processArtifacts } = input; const { sessions, processRuns, processEvents, processArtifacts } = input;
const runById = new Map(processRuns.map((run) => [run.run_id, run])); const runById = new Map(processRuns.map((run) => [run.run_id, run]));
@ -539,7 +552,7 @@ export function buildOfficeView(
const runEvents = eventsByRun.get(run.run_id) ?? []; const runEvents = eventsByRun.get(run.run_id) ?? [];
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun); const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
const status = deriveRunStatus(run, updatedAt, now); const status = deriveRunStatus(run, updatedAt, now);
const stageLabel = deriveStageLabel(run, runEvents, status); const stageLabel = deriveStageLabel(run, runEvents, status, locale);
const childTaskIds = (childrenMap.get(run.run_id) ?? []) const childTaskIds = (childrenMap.get(run.run_id) ?? [])
.filter((child) => taskRunIds.has(child.run_id)) .filter((child) => taskRunIds.has(child.run_id))
.map((child) => child.run_id); .map((child) => child.run_id);
@ -560,7 +573,7 @@ export function buildOfficeView(
finishedAt: run.finished_at ?? null, finishedAt: run.finished_at ?? null,
childTaskIds, childTaskIds,
artifactCount: (artifactsByRun.get(run.run_id) ?? []).length, artifactCount: (artifactsByRun.get(run.run_id) ?? []).length,
errorText: deriveErrorText(run, runEvents), errorText: deriveErrorText(run, runEvents, locale),
isRoot: run.run_id === rootRun.run_id, isRoot: run.run_id === rootRun.run_id,
}; };
}) })
@ -620,9 +633,9 @@ export function buildOfficeView(
rootRun.started_at, rootRun.started_at,
]) ?? rootRun.started_at; ]) ?? rootRun.started_at;
const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now); const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now);
const alerts = buildAlerts(taskViews, now); const alerts = buildAlerts(taskViews, now, locale);
const progress = deriveProgress(rootRun, taskRuns, taskViews); const progress = deriveProgress(rootRun, taskRuns, taskViews, locale);
const sourceSessionLabel = getSessionLabel(sessions, sessionId); const sourceSessionLabel = getSessionLabel(sessions, sessionId, locale);
const createdAt = rootRun.started_at; const createdAt = rootRun.started_at;
const finishedAt = rootRun.finished_at ?? null; const finishedAt = rootRun.finished_at ?? null;
const durationStart = toTime(createdAt); const durationStart = toTime(createdAt);
@ -636,7 +649,7 @@ export function buildOfficeView(
officeId: rootRun.run_id, officeId: rootRun.run_id,
taskId: rootRun.run_id, taskId: rootRun.run_id,
sessionId, sessionId,
title: rootRun.title || `Task ${rootRun.run_id.slice(0, 8)}`, title: rootRun.title || pickAppText(locale, `任务 ${rootRun.run_id.slice(0, 8)}`, `Task ${rootRun.run_id.slice(0, 8)}`),
status: derivedRootStatus, status: derivedRootStatus,
createdAt, createdAt,
updatedAt, updatedAt,
@ -645,7 +658,7 @@ export function buildOfficeView(
sourceSessionLabel, sourceSessionLabel,
rootRunId: rootRun.run_id, rootRunId: rootRun.run_id,
rootActorName: rootRun.actor_name, rootActorName: rootRun.actor_name,
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus), currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus, locale),
progress, progress,
stats: { stats: {
totalRuns: taskRuns.length, totalRuns: taskRuns.length,
@ -657,25 +670,25 @@ export function buildOfficeView(
artifactCount: taskArtifacts.length, artifactCount: taskArtifacts.length,
}, },
alerts, alerts,
zones: buildZones(members, taskViews), zones: buildZones(members, taskViews, locale),
members, members,
tasks: taskViews, tasks: taskViews,
assignments: buildAssignments(taskRuns, childrenMap), assignments: buildAssignments(taskRuns, childrenMap, locale),
detailRunIds: taskViews.map((task) => task.runId), detailRunIds: taskViews.map((task) => task.runId),
}; };
} }
export function buildOfficeTaskList( export function buildOfficeTaskList(
input: BuildOfficeInput & { sessionId?: string | null }, input: BuildOfficeInput & { sessionId?: string | null },
locale: AppLocale = getCurrentAppLocale(),
): OfficeTaskListItem[] { ): OfficeTaskListItem[] {
const rootRuns = findRootRuns(input.processRuns); const rootRuns = findRootRuns(input.processRuns);
const filteredRoots = input.sessionId const offices = rootRuns
? rootRuns.filter((run) => run.session_id === input.sessionId) .map((rootRun) => buildOfficeView(rootRun.run_id, input, locale))
: rootRuns;
return filteredRoots
.map((rootRun) => buildOfficeView(rootRun.run_id, input))
.filter((office): office is OfficeView => office !== null) .filter((office): office is OfficeView => office !== null)
.filter((office) => !input.sessionId || office.sessionId === input.sessionId);
return offices
.map((office) => ({ .map((office) => ({
officeId: office.officeId, officeId: office.officeId,
taskId: office.taskId, taskId: office.taskId,

View File

@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useChatStore } from '@/lib/store';
describe('chat store process event ingestion', () => {
beforeEach(() => {
useChatStore.setState({
sessionId: 'web:alpha',
processRuns: [],
processEvents: [],
processArtifacts: [],
selectedRunId: null,
selectedArtifactId: null,
lastCancelAck: null,
});
});
afterEach(() => {
useChatStore.setState({
sessionId: 'web:default',
processRuns: [],
processEvents: [],
processArtifacts: [],
selectedRunId: null,
selectedArtifactId: null,
lastCancelAck: null,
});
});
it('assigns session_id when the first observed event is progress', () => {
useChatStore.getState().ingestProcessEvent({
type: 'process_run_progress',
session_id: 'web:alpha',
run_id: 'run-progress-only',
parent_run_id: null,
actor_type: 'agent',
actor_id: 'agent-a',
actor_name: 'Agent A',
text: 'still working',
created_at: '2026-03-24T11:20:00.000Z',
});
expect(useChatStore.getState().processRuns).toEqual([
expect.objectContaining({
run_id: 'run-progress-only',
session_id: 'web:alpha',
status: 'running',
title: 'Agent A',
}),
]);
});
});

View File

@ -231,18 +231,21 @@ export const useChatStore = create<ChatStore>((set) => ({
} }
if (event.type === 'process_run_status') { if (event.type === 'process_run_status') {
const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, { nextRuns = upsertRun(nextRuns, {
run_id: event.run_id, run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
actor_type: event.actor_type, actor_type: event.actor_type,
actor_id: event.actor_id, actor_id: event.actor_id,
actor_name: event.actor_name, actor_name: event.actor_name,
title: title:
nextRuns.find((item) => item.run_id === event.run_id)?.title || current?.title || event.actor_name,
event.actor_name, source: current?.source ?? null,
status: event.status, status: event.status,
started_at: started_at:
nextRuns.find((item) => item.run_id === event.run_id)?.started_at || current?.started_at || event.created_at,
event.created_at, metadata: event.metadata,
}); });
} }
@ -250,12 +253,16 @@ export const useChatStore = create<ChatStore>((set) => ({
const current = nextRuns.find((item) => item.run_id === event.run_id); const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, { nextRuns = upsertRun(nextRuns, {
run_id: event.run_id, run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
actor_type: event.actor_type, actor_type: event.actor_type,
actor_id: event.actor_id, actor_id: event.actor_id,
actor_name: event.actor_name, actor_name: event.actor_name,
title: current?.title || event.actor_name, title: current?.title || event.actor_name,
source: current?.source ?? null,
status: current?.status || 'running', status: current?.status || 'running',
started_at: current?.started_at || event.created_at, started_at: current?.started_at || event.created_at,
metadata: event.metadata,
}); });
} }
@ -264,12 +271,15 @@ export const useChatStore = create<ChatStore>((set) => ({
nextRuns = upsertRun(nextRuns, { nextRuns = upsertRun(nextRuns, {
run_id: event.run_id, run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null, parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
actor_type: event.actor_type, actor_type: event.actor_type,
actor_id: event.actor_id, actor_id: event.actor_id,
actor_name: event.actor_name, actor_name: event.actor_name,
title: current?.title || event.actor_name, title: current?.title || event.actor_name,
source: current?.source ?? null,
status: current?.status || 'running', status: current?.status || 'running',
started_at: current?.started_at || event.created_at, started_at: current?.started_at || event.created_at,
metadata: event.metadata,
}); });
} }
@ -295,10 +305,13 @@ export const useChatStore = create<ChatStore>((set) => ({
const current = nextRuns.find((item) => item.run_id === event.run_id); const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, { nextRuns = upsertRun(nextRuns, {
run_id: event.run_id, run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? null,
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
actor_type: event.actor_type, actor_type: event.actor_type,
actor_id: event.actor_id, actor_id: event.actor_id,
actor_name: event.actor_name, actor_name: event.actor_name,
title: current?.title || event.actor_name, title: current?.title || event.actor_name,
source: current?.source ?? null,
status: event.status, status: event.status,
started_at: current?.started_at || event.created_at, started_at: current?.started_at || event.created_at,
finished_at: event.created_at, finished_at: event.created_at,
@ -311,14 +324,17 @@ export const useChatStore = create<ChatStore>((set) => ({
const current = nextRuns.find((item) => item.run_id === event.run_id); const current = nextRuns.find((item) => item.run_id === event.run_id);
nextRuns = upsertRun(nextRuns, { nextRuns = upsertRun(nextRuns, {
run_id: event.run_id, run_id: event.run_id,
parent_run_id: current?.parent_run_id ?? null,
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
actor_type: event.actor_type, actor_type: event.actor_type,
actor_id: event.actor_id, actor_id: event.actor_id,
actor_name: event.actor_name, actor_name: event.actor_name,
title: current?.title || event.actor_name, title: current?.title || event.actor_name,
source: current?.source ?? null,
status: 'cancelled', status: 'cancelled',
started_at: current?.started_at || event.created_at, started_at: current?.started_at || event.created_at,
finished_at: event.created_at, finished_at: event.created_at,
summary: current?.summary ?? '已取消', summary: current?.summary ?? null,
}); });
} }

View File

@ -201,7 +201,6 @@ export interface UiAgentDescriptor {
tags: string[]; tags: string[];
aliases: string[]; aliases: string[];
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
support_group: boolean;
support_streaming: boolean; support_streaming: boolean;
} }

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import type { TokenResponse } from '@/types/auth'; import type { TokenResponse } from '@/types/auth';
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control'; import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
function errorStatus(error: unknown): number { function errorStatus(error: unknown): number {
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.headers.get('accept-language')
);
try { try {
const body = (await request.json()) as { const body = (await request.json()) as {
username?: string; username?: string;
@ -27,7 +33,9 @@ export async function POST(request: NextRequest) {
const password = body.password || ''; const password = body.password || '';
if (!username || !password) { if (!username || !password) {
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 }); return NextResponse.json({
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
}, { status: 400 });
} }
const routing = await callDeployControl<{ const routing = await callDeployControl<{
@ -44,7 +52,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json(normalizeTokenResponse(response, routing)); return NextResponse.json(normalizeTokenResponse(response, routing));
} catch (error) { } catch (error) {
const status = errorStatus(error); const status = errorStatus(error);
const detail = status === 404 || status === 401 ? '用户名或密码错误' : errorDetail(error); const detail = status === 404 || status === 401
? pickPortalText(locale, '用户名或密码错误', 'Incorrect username or password')
: errorDetail(error);
return NextResponse.json({ detail }, { status: status === 404 ? 401 : status }); return NextResponse.json({ detail }, { status: status === 404 ? 401 : status });
} }
} }

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import type { TokenResponse } from '@/types/auth'; import type { TokenResponse } from '@/types/auth';
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control'; import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
function errorStatus(error: unknown): number { function errorStatus(error: unknown): number {
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.headers.get('accept-language')
);
try { try {
const body = (await request.json()) as { const body = (await request.json()) as {
username?: string; username?: string;
@ -29,7 +35,9 @@ export async function POST(request: NextRequest) {
const password = body.password || ''; const password = body.password || '';
if (!username || !password) { if (!username || !password) {
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 }); return NextResponse.json({
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
}, { status: 400 });
} }
const response = await callAuthzService<TokenResponse>('/portal/register', { const response = await callAuthzService<TokenResponse>('/portal/register', {

View File

@ -1,9 +1,11 @@
import './globals.css'; import './globals.css';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { PortalI18nProvider } from '@/lib/i18n/provider';
import { getServerPortalLocale } from '@/lib/i18n/server';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Boardware Agent Sandbox Auth Portal', title: 'Boardware Agent Sandbox Auth Portal',
description: 'Dedicated login and registration portal for Boardware Genius containers.', description: 'Boardware Agent Sandbox Auth Portal',
icons: { icons: {
icon: '/boardware-logo.jpg', icon: '/boardware-logo.jpg',
}, },
@ -14,9 +16,13 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const locale = getServerPortalLocale();
return ( return (
<html lang="zh-CN"> <html lang={locale}>
<body>{children}</body> <body>
<PortalI18nProvider initialLocale={locale}>{children}</PortalI18nProvider>
</body>
</html> </html>
); );
} }

View File

@ -5,9 +5,13 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client'; import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client';
import { pickPortalText } from '@/lib/i18n/core';
import { usePortalI18n } from '@/lib/i18n/provider';
export default function LoginPage() { export default function LoginPage() {
const { locale } = usePortalI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = searchParams?.get('next') || '/'; const nextPath = searchParams?.get('next') || '/';
@ -25,7 +29,7 @@ export default function LoginPage() {
const response = await login(username, password); const response = await login(username, password);
window.location.replace(buildFrontendHandoffUrl(response, nextPath)); window.location.replace(buildFrontendHandoffUrl(response, nextPath));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '登录失败,请稍后重试'); setError(err instanceof Error ? err.message : pickPortalText(locale, '登录失败,请稍后重试', 'Sign-in failed. Please try again.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -33,6 +37,9 @@ export default function LoginPage() {
return ( return (
<main className="portal-page"> <main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<LanguageSwitcher />
</div>
<section className="portal-shell"> <section className="portal-shell">
<div className="portal-brand"> <div className="portal-brand">
<div className="portal-logo-lockup"> <div className="portal-logo-lockup">
@ -47,47 +54,55 @@ export default function LoginPage() {
<div className="portal-kicker">Auth Portal</div> <div className="portal-kicker">Auth Portal</div>
<h1 className="portal-title">Boardware Agent Sandbox</h1> <h1 className="portal-title">Boardware Agent Sandbox</h1>
<p className="portal-copy"> <p className="portal-copy">
URL {pickPortalText(
locale,
'这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL后续前后端请求都留在那套容器里。',
'This portal only handles authentication. After sign-in, you are redirected to your dedicated runtime URL and all later requests stay inside that container.'
)}
</p> </p>
<div className="portal-notes"> <div className="portal-notes">
<div className="portal-note"> <div className="portal-note">
<strong></strong> <strong>{pickPortalText(locale, '容器边界', 'Container boundary')}</strong>
auth portal {pickPortalText(
locale,
'登录注册先经过独立 auth portal再跳到专属实例。一用户一套前后端容器不变。',
'Authentication happens in this standalone portal first, then the browser jumps into the dedicated runtime. Each user keeps an isolated frontend/backend container pair.'
)}
</div> </div>
<div className="portal-note"> <div className="portal-note">
<strong></strong> <strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
<code>{nextPath}</code> {pickPortalText(locale, '当前登录完成后将回到:', 'After sign-in you will return to:')} <code>{nextPath}</code>
</div> </div>
</div> </div>
</div> </div>
<div className="portal-panel"> <div className="portal-panel">
<div className="auth-card"> <div className="auth-card">
<h1></h1> <h1>{pickPortalText(locale, '登录', 'Sign In')}</h1>
<p></p> <p>{pickPortalText(locale, '输入已有账号,认证完成后直接进入目标容器前端。', 'Use an existing account and continue straight into the target runtime UI.')}</p>
<form className="auth-form" onSubmit={handleSubmit}> <form className="auth-form" onSubmit={handleSubmit}>
<div className="field"> <div className="field">
<label htmlFor="username"></label> <label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input <input
id="username" id="username"
value={username} value={username}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
autoComplete="username" autoComplete="username"
placeholder="例如bwgdi" placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
required required
/> />
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="password"></label> <label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input <input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password" autoComplete="current-password"
placeholder="输入密码" placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
required required
/> />
</div> </div>
@ -95,12 +110,14 @@ export default function LoginPage() {
<div className="error-text">{error}</div> <div className="error-text">{error}</div>
<button className="primary-button" type="submit" disabled={loading}> <button className="primary-button" type="submit" disabled={loading}>
{loading ? '登录中...' : '登录并进入容器'} {loading
? pickPortalText(locale, '登录中...', 'Signing in...')
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
</button> </button>
</form> </form>
<div className="auth-footer"> <div className="auth-footer">
<Link href={withNext('/register', nextPath)}></Link> {pickPortalText(locale, '还没有账号?', "Don't have an account yet?")} <Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '去注册', 'Create one')}</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,9 +5,13 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client'; import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client';
import { pickPortalText } from '@/lib/i18n/core';
import { usePortalI18n } from '@/lib/i18n/provider';
export default function RegisterPage() { export default function RegisterPage() {
const { locale } = usePortalI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = searchParams?.get('next') || '/mcp'; const nextPath = searchParams?.get('next') || '/mcp';
@ -25,12 +29,12 @@ export default function RegisterPage() {
try { try {
if (password !== confirmPassword) { if (password !== confirmPassword) {
throw new Error('两次输入的密码不一致'); throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
} }
const response = await register(username, email, password); const response = await register(username, email, password);
window.location.replace(buildFrontendHandoffUrl(response, nextPath)); window.location.replace(buildFrontendHandoffUrl(response, nextPath));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '注册失败,请稍后重试'); setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -38,6 +42,9 @@ export default function RegisterPage() {
return ( return (
<main className="portal-page"> <main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<LanguageSwitcher />
</div>
<section className="portal-shell"> <section className="portal-shell">
<div className="portal-brand"> <div className="portal-brand">
<div className="portal-logo-lockup"> <div className="portal-logo-lockup">
@ -52,72 +59,80 @@ export default function RegisterPage() {
<div className="portal-kicker">Auth Portal</div> <div className="portal-kicker">Auth Portal</div>
<h1 className="portal-title">Create Runtime</h1> <h1 className="portal-title">Create Runtime</h1>
<p className="portal-copy"> <p className="portal-copy">
backend URL {pickPortalText(
locale,
'注册不仅建立登录账号,还会触发专属实例创建和 backend 身份分配。认证完成后会直接进入你的专属 URL。',
'Sign-up not only creates a login account, it also provisions your dedicated runtime and backend identity. After authentication, you go straight into your own URL.'
)}
</p> </p>
<div className="portal-notes"> <div className="portal-notes">
<div className="portal-note"> <div className="portal-note">
<strong></strong> <strong>{pickPortalText(locale, '注册结果', 'Provisioning result')}</strong>
AuthZ deploy-control backend auth portal {pickPortalText(
locale,
'AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化auth portal 最后把你转交到该实例前端。',
'AuthZ coordinates deploy-control to create the runtime, initialize backend identity, and then the portal hands the browser over to that frontend.'
)}
</div> </div>
<div className="portal-note"> <div className="portal-note">
<strong></strong> <strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
<code>{nextPath}</code> {pickPortalText(locale, '当前注册完成后将回到:', 'After sign-up you will return to:')} <code>{nextPath}</code>
</div> </div>
</div> </div>
</div> </div>
<div className="portal-panel"> <div className="portal-panel">
<div className="auth-card"> <div className="auth-card">
<h1></h1> <h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
<p> backend </p> <p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
<form className="auth-form" onSubmit={handleSubmit}> <form className="auth-form" onSubmit={handleSubmit}>
<div className="field"> <div className="field">
<label htmlFor="username"></label> <label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input <input
id="username" id="username"
value={username} value={username}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
autoComplete="username" autoComplete="username"
placeholder="例如bwgdi" placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
required required
/> />
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="email"></label> <label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
<input <input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
autoComplete="email" autoComplete="email"
placeholder="例如steven@example.com" placeholder={pickPortalText(locale, '例如steven@example.com', 'Example: steven@example.com')}
/> />
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="password"></label> <label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input <input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password" autoComplete="new-password"
placeholder="设置密码" placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
required required
/> />
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="confirmPassword"></label> <label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)} onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password" autoComplete="new-password"
placeholder="再次输入密码" placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
required required
/> />
</div> </div>
@ -125,16 +140,18 @@ export default function RegisterPage() {
<div className="error-text">{error}</div> <div className="error-text">{error}</div>
<button className="primary-button" type="submit" disabled={loading}> <button className="primary-button" type="submit" disabled={loading}>
{loading ? '注册中...' : '注册并进入容器'} {loading
? pickPortalText(locale, '注册中...', 'Creating account...')
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
</button> </button>
</form> </form>
<div className="auth-footer"> <div className="auth-footer">
<Link href={withNext('/login', nextPath)}></Link> {pickPortalText(locale, '已有账号?', 'Already have an account?')} <Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '去登录', 'Sign in')}</Link>
</div> </div>
<div className="status-panel"> <div className="status-panel">
Portal URL {pickPortalText(locale, 'Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。', 'The portal first calls the deployment controller to create the runtime, then redirects the browser into the instance URL.')}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,32 @@
'use client';
import { usePortalI18n } from '@/lib/i18n/provider';
const OPTIONS = [
{ value: 'zh-CN', label: 'ZH' },
{ value: 'en-US', label: 'EN' },
] as const;
export function LanguageSwitcher() {
const { locale, setLocale } = usePortalI18n();
return (
<div className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/25 p-1 backdrop-blur">
<span className="ml-1 text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">Lang</span>
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLocale(option.value)}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
locale === option.value
? 'bg-white text-slate-900'
: 'text-white/75 hover:text-white'
}`}
>
{option.label}
</button>
))}
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import type { TokenResponse } from '@/types/auth'; import type { TokenResponse } from '@/types/auth';
import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
const REQUEST_TIMEOUT_MS = 8000; const REQUEST_TIMEOUT_MS = 8000;
const REGISTER_REQUEST_TIMEOUT_MS = 90000; const REGISTER_REQUEST_TIMEOUT_MS = 90000;
@ -28,6 +29,7 @@ function buildApiUrl(path: string): string {
} }
async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> { async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
const locale = getCurrentPortalLocale();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
@ -52,13 +54,13 @@ async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQ
} catch { } catch {
// keep raw text // keep raw text
} }
throw new Error(`接口错误 ${response.status}: ${detail}`); throw new Error(`${pickPortalText(locale, '接口错误', 'API error')} ${response.status}: ${detail}`);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('请求超时'); throw new Error(pickPortalText(locale, '请求超时', 'Request timed out'));
} }
throw error; throw error;
} finally { } finally {
@ -81,13 +83,14 @@ export async function register(username: string, email: string, password: string
} }
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string { export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
const locale = getCurrentPortalLocale();
const frontendBaseUrl = getFrontendBaseUrl(response); const frontendBaseUrl = getFrontendBaseUrl(response);
if (!frontendBaseUrl) { if (!frontendBaseUrl) {
throw new Error('后端未返回目标前端地址'); throw new Error(pickPortalText(locale, '后端未返回目标前端地址', 'Backend did not return a target frontend URL'));
} }
const handoffCode = response.handoff_code?.trim(); const handoffCode = response.handoff_code?.trim();
if (!handoffCode) { if (!handoffCode) {
throw new Error('后端未返回 handoff code'); throw new Error(pickPortalText(locale, '后端未返回 handoff code', 'Backend did not return a handoff code'));
} }
const url = new URL('/handoff', frontendBaseUrl); const url = new URL('/handoff', frontendBaseUrl);

View File

@ -0,0 +1,72 @@
export const PORTAL_LOCALE_COOKIE = 'nanobot_locale';
export const PORTAL_LOCALE_STORAGE_KEY = 'nanobot_locale';
export const PORTAL_LOCALES = ['zh-CN', 'en-US'] as const;
export type PortalLocale = (typeof PORTAL_LOCALES)[number];
export function normalizePortalLocale(value?: string | null): PortalLocale {
const probe = value?.trim().toLowerCase() || '';
if (probe.startsWith('en')) {
return 'en-US';
}
return 'zh-CN';
}
function readCookieLocale(): string | null {
if (typeof document === 'undefined') {
return null;
}
const match = document.cookie
.split('; ')
.find((item) => item.startsWith(`${PORTAL_LOCALE_COOKIE}=`));
if (!match) {
return null;
}
return decodeURIComponent(match.slice(PORTAL_LOCALE_COOKIE.length + 1));
}
export function readBrowserPortalLocale(): PortalLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
const fromDocument = document.documentElement.lang;
if (fromDocument) {
return normalizePortalLocale(fromDocument);
}
const fromStorage = window.localStorage.getItem(PORTAL_LOCALE_STORAGE_KEY);
if (fromStorage) {
return normalizePortalLocale(fromStorage);
}
const fromCookie = readCookieLocale();
if (fromCookie) {
return normalizePortalLocale(fromCookie);
}
return normalizePortalLocale(window.navigator.language);
}
export function persistPortalLocale(locale: PortalLocale): void {
if (typeof window === 'undefined') {
return;
}
document.documentElement.lang = locale;
window.localStorage.setItem(PORTAL_LOCALE_STORAGE_KEY, locale);
document.cookie = `${PORTAL_LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
}
export function getCurrentPortalLocale(): PortalLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
return readBrowserPortalLocale();
}
export function pickPortalText<T>(locale: PortalLocale, zhValue: T, enValue: T): T {
return locale === 'en-US' ? enValue : zhValue;
}

View File

@ -0,0 +1,57 @@
'use client';
import React from 'react';
import {
type PortalLocale,
persistPortalLocale,
readBrowserPortalLocale,
} from '@/lib/i18n/core';
type PortalI18nContextValue = {
locale: PortalLocale;
setLocale: (locale: PortalLocale) => void;
};
const PortalI18nContext = React.createContext<PortalI18nContextValue | null>(null);
export function PortalI18nProvider({
initialLocale,
children,
}: {
initialLocale: PortalLocale;
children: React.ReactNode;
}) {
const [locale, setLocaleState] = React.useState<PortalLocale>(initialLocale);
React.useEffect(() => {
const browserLocale = readBrowserPortalLocale();
if (browserLocale !== locale) {
setLocaleState(browserLocale);
return;
}
persistPortalLocale(locale);
}, []);
React.useEffect(() => {
persistPortalLocale(locale);
}, [locale]);
const value = React.useMemo<PortalI18nContextValue>(
() => ({
locale,
setLocale: setLocaleState,
}),
[locale]
);
return <PortalI18nContext.Provider value={value}>{children}</PortalI18nContext.Provider>;
}
export function usePortalI18n(): PortalI18nContextValue {
const value = React.useContext(PortalI18nContext);
if (!value) {
throw new Error('usePortalI18n must be used within PortalI18nProvider');
}
return value;
}

View File

@ -0,0 +1,17 @@
import { cookies, headers } from 'next/headers';
import { PORTAL_LOCALE_COOKIE, normalizePortalLocale, type PortalLocale } from '@/lib/i18n/core';
export function getServerPortalLocale(): PortalLocale {
const cookieLocale = cookies().get(PORTAL_LOCALE_COOKIE)?.value;
if (cookieLocale) {
return normalizePortalLocale(cookieLocale);
}
const acceptLanguage = headers().get('accept-language');
if (acceptLanguage) {
return normalizePortalLocale(acceptLanguage);
}
return 'zh-CN';
}

File diff suppressed because one or more lines are too long