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:
@ -1,3 +1,4 @@
|
||||
https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65
|
||||
# nano_project
|
||||
|
||||
单机部署版运行结构:
|
||||
|
||||
@ -64,11 +64,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
|
||||
COPY backend/nanobot/ ./nanobot/
|
||||
COPY backend/bridge/ ./bridge/
|
||||
COPY backend/third_party/swarms/ ./third_party/swarms/
|
||||
RUN uv pip install --system --no-cache .
|
||||
|
||||
WORKDIR /opt/app/backend/bridge
|
||||
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-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \
|
||||
npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \
|
||||
|
||||
@ -23,11 +23,14 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY bridge/ bridge/
|
||||
COPY third_party/swarms/ third_party/swarms/
|
||||
RUN uv pip install --system --no-cache .
|
||||
|
||||
# Build the WhatsApp 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
|
||||
|
||||
# Create config directory
|
||||
|
||||
0
app-instance/backend/agent_workspace/error.txt
Normal file
0
app-instance/backend/agent_workspace/error.txt
Normal file
@ -22,7 +22,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
import httpx
|
||||
|
||||
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):
|
||||
@ -204,7 +204,7 @@ class A2AClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout_seconds: int = 30,
|
||||
timeout_seconds: int = 600,
|
||||
poll_interval_seconds: int = 2,
|
||||
card_cache_ttl_seconds: int = 300,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
@ -1123,10 +1123,13 @@ class A2AClient:
|
||||
summary = state.build_summary(self)
|
||||
if not summary:
|
||||
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(
|
||||
agent_id=agent.id,
|
||||
agent_name=agent.name,
|
||||
status=self._normalize_status(result.get("status")),
|
||||
status=status,
|
||||
summary=summary,
|
||||
raw=result,
|
||||
)
|
||||
|
||||
@ -21,6 +21,7 @@ from nanobot.agent.plugins import PluginLoader
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9_-]+")
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -55,7 +56,6 @@ class AgentDescriptor:
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
support_group: bool = True
|
||||
support_streaming: bool = False
|
||||
|
||||
def matches(self, target: str) -> bool:
|
||||
@ -236,7 +236,6 @@ class AgentRegistry:
|
||||
kind="local_fallback",
|
||||
protocol=None,
|
||||
aliases=["subagent", "local"],
|
||||
support_group=True,
|
||||
)
|
||||
)
|
||||
|
||||
@ -263,27 +262,51 @@ class AgentRegistry:
|
||||
|
||||
def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]:
|
||||
"""基于简单词项打分为一段任务文本推荐 agent。"""
|
||||
tokens = {token for token in _TOKEN_RE.findall((query or "").lower()) if len(token) > 2}
|
||||
if not tokens:
|
||||
return []
|
||||
query_text = query or ""
|
||||
query_lower = query_text.lower()
|
||||
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]] = []
|
||||
for agent in self.list_agents(include_local_fallback=False):
|
||||
haystack = agent.searchable_text()
|
||||
haystack_cjk_bigrams = self._cjk_bigrams(haystack)
|
||||
score = 0
|
||||
for token in tokens:
|
||||
# token 命中一次给基础分。
|
||||
if token in haystack:
|
||||
score += 2
|
||||
# 如果查询里直接出现了 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
|
||||
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:
|
||||
scored.append((score, agent))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1].name.lower()))
|
||||
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:
|
||||
"""把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。"""
|
||||
agents = self.list_agents()
|
||||
@ -310,9 +333,6 @@ class AgentRegistry:
|
||||
lines.append(f" <protocol>{esc(agent.protocol)}</protocol>")
|
||||
if agent.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("</agents>")
|
||||
return "\n".join(lines)
|
||||
@ -358,7 +378,6 @@ class AgentRegistry:
|
||||
],
|
||||
capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), 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)),
|
||||
)
|
||||
|
||||
@ -396,6 +415,5 @@ class AgentRegistry:
|
||||
],
|
||||
capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), 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)),
|
||||
)
|
||||
|
||||
@ -28,6 +28,8 @@ from nanobot.agent.process_events import (
|
||||
process_run_context,
|
||||
)
|
||||
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.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
@ -61,12 +63,13 @@ class DelegationManager:
|
||||
def __init__(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str | None,
|
||||
workspace: Path,
|
||||
bus: MessageBus,
|
||||
registry: AgentRegistry,
|
||||
skills_loader: "SkillsLoader | None",
|
||||
local_executor: Any,
|
||||
timeout_seconds: int = 30,
|
||||
timeout_seconds: int = 600,
|
||||
poll_interval_seconds: int = 2,
|
||||
card_cache_ttl_seconds: int = 300,
|
||||
max_parallel_agents: int = 4,
|
||||
@ -76,6 +79,7 @@ class DelegationManager:
|
||||
allow_local_delegation: bool = True,
|
||||
allow_plugin_delegation: bool = True,
|
||||
allow_local_fallback: bool = True,
|
||||
gateway_port: int = 18790,
|
||||
):
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
@ -97,6 +101,18 @@ class DelegationManager:
|
||||
authz_config=authz_config,
|
||||
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._direct_announcement_callback: DirectAnnouncementCallback | None = None
|
||||
|
||||
@ -273,6 +289,20 @@ class DelegationManager:
|
||||
"""返回当前正在执行的委派数量。"""
|
||||
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
|
||||
def _ui_status(status: str | None) -> str:
|
||||
"""把底层状态归一化成前端更稳定的显示状态。"""
|
||||
@ -287,6 +317,29 @@ class DelegationManager:
|
||||
return "error"
|
||||
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(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -310,7 +363,6 @@ class DelegationManager:
|
||||
metadata={
|
||||
"kind": descriptor.kind,
|
||||
"protocol": descriptor.protocol,
|
||||
"support_group": descriptor.support_group,
|
||||
"support_streaming": descriptor.support_streaming,
|
||||
"delegated_task": task,
|
||||
},
|
||||
@ -371,31 +423,56 @@ class DelegationManager:
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Team",
|
||||
source="agent_team",
|
||||
title=label,
|
||||
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:
|
||||
"""发送 agent team 结束事件。"""
|
||||
async def _emit_group_finished(
|
||||
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(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Team",
|
||||
status="done",
|
||||
summary=f"{label}: {len(results)} member(s) finished",
|
||||
metadata={
|
||||
"members": [
|
||||
{
|
||||
"agent_id": item.agent_id,
|
||||
"agent_name": item.agent_name,
|
||||
"status": item.status,
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
},
|
||||
status=status,
|
||||
summary=summary or f"{label}: {len(results)} member(s) finished",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def _publish_prefixed_progress(
|
||||
@ -424,27 +501,11 @@ class DelegationManager:
|
||||
# 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。
|
||||
if not has_process_event_sink():
|
||||
return
|
||||
try:
|
||||
# 用一次极小模型调用把内部委派说明压成用户可读文本。
|
||||
response = await self.provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"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
|
||||
# 这条用户可见消息只是“即时回执”,真正详细总结仍由主 agent 回流处理。
|
||||
# 这里不再额外依赖一次 LLM,避免 provider 短暂故障把 team 收尾也拖失败。
|
||||
content = " ".join((fallback or prompt or "").strip().split())
|
||||
if not content:
|
||||
return
|
||||
|
||||
await emit_process_event(
|
||||
"message",
|
||||
@ -473,11 +534,42 @@ class DelegationManager:
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> None:
|
||||
"""在非 bus 模式下,把公告直接回写到本地会话。"""
|
||||
callback = self._direct_announcement_callback
|
||||
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
|
||||
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:
|
||||
await callback(
|
||||
content,
|
||||
@ -485,7 +577,34 @@ class DelegationManager:
|
||||
sender_id,
|
||||
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:
|
||||
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)
|
||||
|
||||
async def _run_dispatch(
|
||||
@ -510,23 +629,80 @@ class DelegationManager:
|
||||
if is_group:
|
||||
planned_targets = list(targets)
|
||||
await self._emit_group_started(run_id, label, planned_targets)
|
||||
results = await self._run_group(
|
||||
task,
|
||||
label,
|
||||
None,
|
||||
targets,
|
||||
strategy,
|
||||
skills,
|
||||
origin=origin,
|
||||
run_id=run_id,
|
||||
announce_via_bus=announce_via_bus,
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Agent team dispatch accepted and moved into swarms orchestration.",
|
||||
stage_label="开始团队编排",
|
||||
metadata={
|
||||
"phase": "dispatch",
|
||||
"strategy": strategy,
|
||||
"execution_path": "swarms",
|
||||
"announce_via_bus": announce_via_bus,
|
||||
"requested_targets": planned_targets,
|
||||
},
|
||||
)
|
||||
await self._emit_group_finished(run_id, label, results)
|
||||
await self._announce_group_result(
|
||||
logger.info(
|
||||
"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,
|
||||
label,
|
||||
task,
|
||||
results,
|
||||
orchestrated,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
@ -591,6 +767,16 @@ class DelegationManager:
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
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(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
@ -777,94 +963,57 @@ class DelegationManager:
|
||||
and ("subagent" in lowered or "sub-agent" in lowered)
|
||||
)
|
||||
|
||||
async def _run_group(
|
||||
async def _run_team_member_for_swarms(
|
||||
self,
|
||||
descriptor: AgentDescriptor,
|
||||
task: str,
|
||||
label: str,
|
||||
target: str | None,
|
||||
targets: list[str],
|
||||
strategy: str,
|
||||
parent_run_id: str,
|
||||
skills: list[str],
|
||||
origin: dict[str, str],
|
||||
run_id: str,
|
||||
announce_via_bus: bool,
|
||||
) -> list[AgentRunResult]:
|
||||
"""并行执行一组 agent,并汇总结果。"""
|
||||
resolved_targets = list(targets)
|
||||
if target:
|
||||
resolved_targets.append(target)
|
||||
if not resolved_targets:
|
||||
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
|
||||
suggestions = [
|
||||
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2)
|
||||
if self._descriptor_allowed(agent)
|
||||
]
|
||||
resolved_targets = [agent.id for agent in suggestions]
|
||||
if not resolved_targets:
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor and self._descriptor_allowed(descriptor):
|
||||
resolved_targets = [descriptor.id]
|
||||
if not resolved_targets:
|
||||
raise ValueError("No agents available for group delegation")
|
||||
resolved_targets = list(dict.fromkeys(resolved_targets))
|
||||
|
||||
descriptors: list[AgentDescriptor] = []
|
||||
missing: list[str] = []
|
||||
for item in resolved_targets:
|
||||
descriptor = self.registry.get_agent(item)
|
||||
if descriptor is None:
|
||||
missing.append(item)
|
||||
else:
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
descriptors.append(descriptor)
|
||||
if missing:
|
||||
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
||||
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_agents)
|
||||
|
||||
async def _run_one(descriptor: AgentDescriptor) -> AgentRunResult:
|
||||
# group 内每个成员都分配独立 child run_id,便于前端区分子树。
|
||||
child_run_id = new_run_id("agent")
|
||||
async with semaphore:
|
||||
try:
|
||||
await self._emit_agent_started(
|
||||
child_run_id,
|
||||
descriptor,
|
||||
label,
|
||||
parent_run_id=run_id,
|
||||
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
|
||||
) -> AgentRunResult:
|
||||
"""Execute one swarms-selected nanobot agent as a process child run."""
|
||||
state = self._running_tasks.get(parent_run_id)
|
||||
label = "Agent Team" if state is None else state.label
|
||||
origin = {"channel": "system", "chat_id": "direct"} if state is None else state.origin
|
||||
announce_via_bus = True if state is None else state.announce_via_bus
|
||||
child_run_id = new_run_id("agent")
|
||||
try:
|
||||
self._ensure_descriptor_allowed(descriptor)
|
||||
await self._emit_agent_started(
|
||||
child_run_id,
|
||||
descriptor,
|
||||
label,
|
||||
parent_run_id=parent_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=parent_run_id,
|
||||
publish_via_bus=announce_via_bus,
|
||||
),
|
||||
task_callback=self._build_task_callback(parent_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
|
||||
|
||||
async def _execute_descriptor(
|
||||
self,
|
||||
@ -1164,52 +1313,102 @@ class DelegationManager:
|
||||
)
|
||||
logger.debug("Delegation [{}] announced result", run_id)
|
||||
|
||||
async def _announce_group_result(
|
||||
async def _announce_orchestrator_result(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
task: str,
|
||||
results: list[AgentRunResult],
|
||||
result: BridgeResult,
|
||||
origin: dict[str, str],
|
||||
*,
|
||||
announce_via_bus: bool,
|
||||
) -> None:
|
||||
"""公告 agent team 汇总结果。"""
|
||||
lines = [f"[Agent team '{label}' completed]", "", f"Task: {task}", "", "Members:"]
|
||||
for result in results:
|
||||
lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}")
|
||||
lines.extend(["", "Results:"])
|
||||
for result in results:
|
||||
lines.append(f"### {result.agent_name} ({result.status})")
|
||||
lines.append(result.summary)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Summarize this naturally for the user. Mention disagreements or failures if any."
|
||||
"""公告 orchestrator 驱动的 agent team 结果。
|
||||
|
||||
Demo 输出:
|
||||
`[Agent team 'weekly report' completed]\nExecution mode: swarms\nMatched procedure: procedure-a1b2c3d4`
|
||||
"""
|
||||
# 这里显式保留 mode / procedure 信息,方便主 agent 做更准确的用户总结。
|
||||
await self._emit_team_progress(
|
||||
run_id,
|
||||
"Preparing orchestrated agent-team summary for the main agent.",
|
||||
stage_label="整理团队结果",
|
||||
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()
|
||||
if announce_via_bus:
|
||||
await self._publish_announcement(
|
||||
summary,
|
||||
origin,
|
||||
sender_id="delegation-group",
|
||||
sender_id="delegation-team",
|
||||
run_id=run_id,
|
||||
category="agent_team_orchestrated",
|
||||
)
|
||||
else:
|
||||
await self._notify_direct_announcement(
|
||||
summary,
|
||||
origin,
|
||||
"delegation-group",
|
||||
"delegation-team",
|
||||
run_id=run_id,
|
||||
category="agent_team_orchestrated",
|
||||
)
|
||||
await self._emit_direct_user_message(
|
||||
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(
|
||||
self,
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> None:
|
||||
"""通过 system inbound 消息把公告重新送回主 agent 链路。"""
|
||||
msg = InboundMessage(
|
||||
@ -1219,3 +1418,18 @@ class DelegationManager:
|
||||
content=content,
|
||||
)
|
||||
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"),
|
||||
},
|
||||
)
|
||||
|
||||
@ -83,6 +83,7 @@ class AgentLoop:
|
||||
allow_local_delegation: bool = True,
|
||||
allow_plugin_delegation: bool = True,
|
||||
include_plugin_agents: bool = True,
|
||||
gateway_port: int = 18790,
|
||||
):
|
||||
from nanobot.config.schema import A2AConfig, ExecToolConfig
|
||||
# 基础依赖与运行参数。
|
||||
@ -142,6 +143,7 @@ class AgentLoop:
|
||||
)
|
||||
self.delegation = DelegationManager(
|
||||
provider=provider,
|
||||
model=self.model,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
registry=self.agent_registry,
|
||||
@ -157,6 +159,7 @@ class AgentLoop:
|
||||
allow_local_delegation=self.allow_local_delegation,
|
||||
allow_plugin_delegation=self.allow_plugin_delegation,
|
||||
allow_local_fallback=self.include_local_fallback,
|
||||
gateway_port=gateway_port,
|
||||
)
|
||||
self.subagents.set_nested_delegate(self.delegation)
|
||||
|
||||
|
||||
@ -6,6 +6,42 @@ from dataclasses import dataclass
|
||||
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
|
||||
class AgentRunResult:
|
||||
"""统一描述一次 agent 执行结果。"""
|
||||
|
||||
@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
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.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
@ -147,15 +147,24 @@ class SubagentManager:
|
||||
final_result = response.content
|
||||
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."
|
||||
status = "error"
|
||||
raw = {
|
||||
"reason": "no_final_response_generated",
|
||||
"iterations": iteration,
|
||||
}
|
||||
|
||||
return AgentRunResult(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
status="ok",
|
||||
status=status,
|
||||
summary=final_result,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def _build_local_tools(
|
||||
|
||||
@ -174,7 +174,6 @@ class LocalSubagentStore:
|
||||
"local_subagent": True,
|
||||
},
|
||||
"capabilities": {"streaming": False},
|
||||
"support_group": False,
|
||||
"support_streaming": False,
|
||||
}
|
||||
|
||||
|
||||
63
app-instance/backend/nanobot/agent_team/__init__.py
Normal file
63
app-instance/backend/nanobot/agent_team/__init__.py
Normal 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)
|
||||
361
app-instance/backend/nanobot/agent_team/memory.py
Normal file
361
app-instance/backend/nanobot/agent_team/memory.py
Normal 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
|
||||
241
app-instance/backend/nanobot/agent_team/orchestrator.py
Normal file
241
app-instance/backend/nanobot/agent_team/orchestrator.py
Normal 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
|
||||
]
|
||||
185
app-instance/backend/nanobot/agent_team/provisioning.py
Normal file
185
app-instance/backend/nanobot/agent_team/provisioning.py
Normal 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}"
|
||||
)
|
||||
@ -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 adapter;adapter 把 swarms conversation context 转回 nanobot 成员任务。
|
||||
-》 DelegationManager._run_team_member_for_swarms()
|
||||
作用:为该成员创建 child run,做权限检查,发 agent started/finished 事件。
|
||||
-》 DelegationManager._execute_descriptor()
|
||||
作用:真正执行成员 agent;local_prompt/local_fallback 走 local_executor,A2A 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_names;A2A 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/error,metadata 带 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 等共享类型。
|
||||
```
|
||||
114
app-instance/backend/nanobot/agent_team/swarms_adapter.py
Normal file
114
app-instance/backend/nanobot/agent_team/swarms_adapter.py
Normal 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()
|
||||
302
app-instance/backend/nanobot/agent_team/swarms_bridge.py
Normal file
302
app-instance/backend/nanobot/agent_team/swarms_bridge.py
Normal 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 {}),
|
||||
},
|
||||
)
|
||||
184
app-instance/backend/nanobot/agent_team/swarms_planner.py
Normal file
184
app-instance/backend/nanobot/agent_team/swarms_planner.py
Normal 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."
|
||||
)
|
||||
70
app-instance/backend/nanobot/agent_team/swarms_policy.py
Normal file
70
app-instance/backend/nanobot/agent_team/swarms_policy.py
Normal 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"),
|
||||
}
|
||||
267
app-instance/backend/nanobot/agent_team/target_resolver.py
Normal file
267
app-instance/backend/nanobot/agent_team/target_resolver.py
Normal 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 直接选择最合适的已有 agent;LLM 选不出来时才 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
|
||||
546
app-instance/backend/nanobot/agent_team/types.py
Normal file
546
app-instance/backend/nanobot/agent_team/types.py
Normal 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 {},
|
||||
)
|
||||
@ -287,7 +287,10 @@ def _make_provider(config: Config):
|
||||
|
||||
# OpenAI Codex (OAuth)
|
||||
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
|
||||
if provider_name == "custom":
|
||||
@ -295,6 +298,7 @@ def _make_provider(config: Config):
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||
default_model=model,
|
||||
request_timeout_seconds=p.request_timeout_seconds if p else 600,
|
||||
)
|
||||
|
||||
# LiteLLM 通道:绝大多数 provider 走这里。
|
||||
@ -311,6 +315,7 @@ def _make_provider(config: Config):
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
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,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
# 把 cron 执行回调绑定到 agent:定时触发时会走一次完整 agent 处理流程。
|
||||
@ -523,6 +529,7 @@ def web(
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
config = load_config()
|
||||
config.gateway.port = port
|
||||
_create_workspace_templates(config.workspace_path)
|
||||
|
||||
console.print(f"{__brand__}: starting web backend on {host}:{port}...")
|
||||
@ -596,6 +603,7 @@ def agent(
|
||||
channels_config=config.channels,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
# `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。
|
||||
@ -1217,6 +1225,7 @@ def cron_run(
|
||||
channels_config=config.channels,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
store_path = get_cron_store_path(config.workspace_path)
|
||||
|
||||
@ -288,6 +288,7 @@ class ProviderConfig(Base):
|
||||
api_key: str = ""
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
request_timeout_seconds: int = 600
|
||||
|
||||
|
||||
class ProvidersConfig(Base):
|
||||
@ -368,7 +369,7 @@ class A2AConfig(Base):
|
||||
# 总开关,预留给未来需要完全禁用远程委派的场景。
|
||||
enabled: bool = True
|
||||
# 单次远程任务的最长等待时间(秒)。
|
||||
timeout_seconds: int = 30
|
||||
timeout_seconds: int = 600
|
||||
# 非流式任务轮询间隔(秒)。
|
||||
poll_interval_seconds: int = 2
|
||||
# agent card 本地缓存 TTL,避免每次委派都重新拉远端元数据。
|
||||
|
||||
@ -36,9 +36,19 @@ class LLMProvider(ABC):
|
||||
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_base = api_base
|
||||
self.request_timeout_seconds = (
|
||||
max(1.0, float(request_timeout_seconds))
|
||||
if request_timeout_seconds is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@ -12,10 +12,20 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
class CustomProvider(LLMProvider):
|
||||
|
||||
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
|
||||
super().__init__(api_key, api_base)
|
||||
def __init__(
|
||||
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._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,
|
||||
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:
|
||||
return self.default_model
|
||||
|
||||
|
||||
@ -44,8 +44,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
default_model: str = "anthropic/claude-opus-4-5",
|
||||
extra_headers: dict[str, 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.extra_headers = extra_headers or {}
|
||||
|
||||
@ -230,6 +231,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Pass extra headers (e.g. APP-Code for AiHubMix)
|
||||
if 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:
|
||||
kwargs["tools"] = tools
|
||||
@ -246,6 +250,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
"has_api_key": bool(self.api_key),
|
||||
"temperature": kwargs.get("temperature"),
|
||||
"max_tokens": kwargs.get("max_tokens"),
|
||||
"timeout": kwargs.get("timeout"),
|
||||
"tool_choice": kwargs.get("tool_choice"),
|
||||
"message_count": len(sanitized_messages),
|
||||
"messages": summarize_messages(sanitized_messages),
|
||||
|
||||
@ -20,8 +20,12 @@ DEFAULT_ORIGINATOR = "nanobot"
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
"""Use Codex OAuth to call the Responses API."""
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
def __init__(
|
||||
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
|
||||
|
||||
async def chat(
|
||||
@ -58,12 +62,24 @@ class OpenAICodexProvider(LLMProvider):
|
||||
|
||||
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:
|
||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||
raise
|
||||
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(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
@ -102,8 +118,9 @@ async def _request_codex(
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
timeout_seconds: float,
|
||||
) -> 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:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
|
||||
@ -42,7 +42,7 @@ from nanobot.cron.runtime import run_cron_job
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -281,23 +281,6 @@ def _slugify_agent_id(*values: Any) -> str:
|
||||
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(
|
||||
req: AddAgentRequest,
|
||||
config: Config,
|
||||
@ -377,7 +360,6 @@ async def _discover_agent_payload(
|
||||
"tags": _dedupe_texts(req.tags, card.get("tags")),
|
||||
"aliases": _dedupe_texts(req.aliases, card.get("aliases")),
|
||||
"capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {},
|
||||
"support_group": _card_supports_group(card),
|
||||
"support_streaming": client._supports_streaming(card),
|
||||
"metadata": dict(req.metadata or {}),
|
||||
}
|
||||
@ -652,6 +634,7 @@ def create_app(
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
authz_config=config.authz,
|
||||
backend_identity=config.backend_identity,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
|
||||
async def _handle_direct_delegation_announcement(
|
||||
@ -767,13 +750,17 @@ def _make_provider(config: Config):
|
||||
p = config.get_provider(model)
|
||||
|
||||
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":
|
||||
return CustomProvider(
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||
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/"):
|
||||
@ -785,6 +772,7 @@ def _make_provider(config: Config):
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
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_plugin_delegation=allow_local,
|
||||
include_plugin_agents=allow_local,
|
||||
gateway_port=config.gateway.port,
|
||||
)
|
||||
try:
|
||||
return await loop.process_direct(
|
||||
|
||||
@ -44,6 +44,21 @@ dependencies = [
|
||||
"json-repair>=0.57.0,<1.0.0",
|
||||
"fastapi>=0.115.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]
|
||||
|
||||
1
app-instance/backend/third_party/swarms
vendored
Submodule
1
app-instance/backend/third_party/swarms
vendored
Submodule
Submodule app-instance/backend/third_party/swarms added at fe1609f9d5
203
app-instance/backend/uv.lock
generated
203
app-instance/backend/uv.lock
generated
@ -309,11 +309,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "6.0.0.post1"
|
||||
version = "5.2.0"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@ -1536,6 +1536,9 @@ name = "nanobot-ai"
|
||||
version = "0.1.4.post1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "chardet" },
|
||||
{ name = "croniter" },
|
||||
{ name = "dingtalk-stream" },
|
||||
{ name = "fastapi" },
|
||||
@ -1546,19 +1549,31 @@ dependencies = [
|
||||
{ name = "loguru" },
|
||||
{ name = "mcp" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "networkx" },
|
||||
{ name = "numpy" },
|
||||
{ name = "oauth-cli-kit" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "python-socks" },
|
||||
{ name = "python-telegram-bot", extra = ["socks"] },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qq-botpy" },
|
||||
{ name = "ratelimit" },
|
||||
{ name = "readability-lxml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "schedule" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "slackify-markdown" },
|
||||
{ name = "socksio" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "toml" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websocket-client" },
|
||||
@ -1582,6 +1597,9 @@ matrix = [
|
||||
|
||||
[package.metadata]
|
||||
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 = "dingtalk-stream", specifier = ">=0.24.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 == 'matrix'", specifier = ">=3.0.0,<4.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 == '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 = "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-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-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-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.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 = "ratelimit", specifier = ">=2.2.1" },
|
||||
{ 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 = "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 = "slackify-markdown", specifier = ">=0.2.0,<1.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 = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||
{ name = "websocket-client", specifier = ">=1.9.0,<2.0.0" },
|
||||
@ -1621,6 +1651,15 @@ requires-dist = [
|
||||
]
|
||||
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]]
|
||||
name = "nh3"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "oauth-cli-kit"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@ -2022,6 +2168,15 @@ crypto = [
|
||||
{ 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]]
|
||||
name = "pytest"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "readability-lxml"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "shellingham"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tiktoken"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tqdm"
|
||||
version = "4.67.3"
|
||||
|
||||
@ -21,6 +21,40 @@ require_file() {
|
||||
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() {
|
||||
local status=$?
|
||||
|
||||
@ -51,6 +85,14 @@ fi
|
||||
require_file "$NANOBOT_HOME/config.json" "Missing Boardware Genius config"
|
||||
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_RUNTIME_ENV_FILE
|
||||
export PORT="$APP_FRONTEND_PORT"
|
||||
|
||||
@ -264,7 +264,6 @@ export interface UiAgentDescriptor {
|
||||
protocol: string | null;
|
||||
tags: string[];
|
||||
aliases: string[];
|
||||
support_group: boolean;
|
||||
support_streaming: boolean;
|
||||
}
|
||||
|
||||
@ -611,7 +610,6 @@ MCP 页面建议分两块:
|
||||
- protocol
|
||||
- tags
|
||||
- aliases
|
||||
- support_group
|
||||
- support_streaming
|
||||
- endpoint / base_url / card_url
|
||||
|
||||
@ -790,4 +788,3 @@ MCP 页面建议分两块:
|
||||
- `nanobot/a2a/client.py`
|
||||
- `nanobot/agent/tools/mcp.py`
|
||||
- `nanobot/web/server.py`
|
||||
|
||||
|
||||
@ -35,6 +35,9 @@ import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { AppLocale } from '@/lib/i18n/core';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
const EMPTY_AGENT_FORM = {
|
||||
id: '',
|
||||
@ -70,7 +73,7 @@ function formatJson(value: Record<string, unknown>): string {
|
||||
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();
|
||||
if (!probe) {
|
||||
return {};
|
||||
@ -79,25 +82,46 @@ function parseJsonObject(raw: string, label: string): Record<string, unknown> {
|
||||
try {
|
||||
parsed = JSON.parse(probe);
|
||||
} 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)) {
|
||||
throw new Error(`${label} 需要是 JSON 对象`);
|
||||
throw new Error(`${label} ${pickAppText(locale, '需要是 JSON 对象', 'must be a JSON object')}`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> {
|
||||
const parsed = parseJsonObject(raw, label);
|
||||
function parseNestedJsonObject(raw: string, label: string, locale: AppLocale): Record<string, Record<string, unknown>> {
|
||||
const parsed = parseJsonObject(raw, label, locale);
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
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>>;
|
||||
}
|
||||
|
||||
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() {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
const cachedAgents = useChatStore((s) => s.agentRegistry);
|
||||
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
|
||||
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
|
||||
@ -133,7 +157,7 @@ export default function AgentsPage() {
|
||||
setSubagents(nextSubagents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载智能体失败');
|
||||
setError(err.message || t('加载智能体失败', 'Failed to load agents'));
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
@ -161,7 +185,7 @@ export default function AgentsPage() {
|
||||
setSubagents(nextSubagents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '刷新智能体失败');
|
||||
setError(err.message || t('刷新智能体失败', 'Failed to refresh agents'));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@ -188,7 +212,7 @@ export default function AgentsPage() {
|
||||
e.preventDefault();
|
||||
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim());
|
||||
if (!hasAddress) {
|
||||
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
|
||||
setError(t('请至少填写 A2A 部署地址、接口地址或卡片地址', 'Enter at least an A2A base URL, endpoint, or card URL'));
|
||||
return;
|
||||
}
|
||||
setAgentSubmitting(true);
|
||||
@ -214,7 +238,7 @@ export default function AgentsPage() {
|
||||
handleAgentDialogOpenChange(false);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '新增智能体失败');
|
||||
setError(err.message || t('新增智能体失败', 'Failed to create the agent'));
|
||||
} finally {
|
||||
setAgentSubmitting(false);
|
||||
}
|
||||
@ -225,7 +249,7 @@ export default function AgentsPage() {
|
||||
await deleteAgent(agentId);
|
||||
await load(true);
|
||||
} 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) => {
|
||||
e.preventDefault();
|
||||
if (!subagentForm.id.trim()) {
|
||||
setError('Sub-agent ID 不能为空');
|
||||
setError(t('Sub-agent ID 不能为空', 'Sub-agent ID cannot be empty'));
|
||||
return;
|
||||
}
|
||||
setSubagentSubmitting(true);
|
||||
@ -268,8 +292,8 @@ export default function AgentsPage() {
|
||||
allow_mcp: subagentForm.allow_mcp,
|
||||
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'),
|
||||
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'),
|
||||
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata', locale),
|
||||
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers', locale),
|
||||
};
|
||||
if (editingSubagentId) {
|
||||
await updateSubagent(editingSubagentId, payload);
|
||||
@ -279,7 +303,7 @@ export default function AgentsPage() {
|
||||
handleSubagentDialogOpenChange(false);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存 Sub-Agent 失败');
|
||||
setError(err.message || t('保存 Sub-Agent 失败', 'Failed to save the sub-agent'));
|
||||
} finally {
|
||||
setSubagentSubmitting(false);
|
||||
}
|
||||
@ -290,7 +314,7 @@ export default function AgentsPage() {
|
||||
await deleteSubagent(subagentId);
|
||||
await load(true);
|
||||
} 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>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="w-6 h-6" />
|
||||
智能体
|
||||
{t('智能体', 'Agents')}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增智能体
|
||||
{t('新增智能体', 'Add agent')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增工作区智能体</DialogTitle>
|
||||
<DialogTitle>{t('新增工作区智能体', 'Add workspace agent')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleCreateAgent}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base_url">A2A 部署地址</Label>
|
||||
<Label htmlFor="base_url">{t('A2A 部署地址', 'A2A base URL')}</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
value={agentForm.base_url}
|
||||
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">
|
||||
默认只需要填写部署地址。保存时会自动读取
|
||||
{t('默认只需要填写部署地址。保存时会自动读取', 'Usually the base URL is enough. Save will auto-read')}
|
||||
<code className="mx-1">/.well-known</code>
|
||||
路径并补齐 card 信息。
|
||||
{t('路径并补齐 card 信息。', 'and complete the card metadata.')}
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<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' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
@ -359,27 +383,27 @@ export default function AgentsPage() {
|
||||
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Label htmlFor="name">{t('名称', 'Name')}</Label>
|
||||
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Label htmlFor="description">{t('描述', 'Description')}</Label>
|
||||
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint">接口地址</Label>
|
||||
<Label htmlFor="endpoint">{t('接口地址', 'Endpoint URL')}</Label>
|
||||
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card_url">卡片地址</Label>
|
||||
<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 }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||
<Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
|
||||
<select
|
||||
id="auth_mode"
|
||||
value={agentForm.auth_mode}
|
||||
@ -400,31 +424,34 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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 }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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 }))} />
|
||||
</div>
|
||||
<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 }))} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<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 className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
|
||||
取消
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={agentSubmitting}>
|
||||
{agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
保存
|
||||
{t('保存', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -434,12 +461,12 @@ export default function AgentsPage() {
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增 Sub-Agent
|
||||
{t('新增 Sub-Agent', 'Add sub-agent')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={handleSaveSubagent}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@ -454,7 +481,7 @@ export default function AgentsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_name">名称</Label>
|
||||
<Label htmlFor="subagent_name">{t('名称', 'Name')}</Label>
|
||||
<Input
|
||||
id="subagent_name"
|
||||
value={subagentForm.name}
|
||||
@ -464,13 +491,13 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_description">描述</Label>
|
||||
<Label htmlFor="subagent_description">{t('描述', 'Description')}</Label>
|
||||
<Textarea
|
||||
id="subagent_description"
|
||||
rows={3}
|
||||
value={subagentForm.description}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
|
||||
placeholder="用于研究和资料整理的本地持久化子智能体"
|
||||
placeholder={t('用于研究和资料整理的本地持久化子智能体', 'A persistent local sub-agent for research and note taking')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -485,16 +512,16 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_model">模型</Label>
|
||||
<Label htmlFor="subagent_model">{t('模型', 'Model')}</Label>
|
||||
<Input
|
||||
id="subagent_model"
|
||||
value={subagentForm.model}
|
||||
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
|
||||
placeholder="留空则继承主 Agent 默认模型"
|
||||
placeholder={t('留空则继承主 Agent 默认模型', 'Leave blank to inherit the lead agent model')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delegation_mode">委派模式</Label>
|
||||
<Label htmlFor="delegation_mode">{t('委派模式', 'Delegation mode')}</Label>
|
||||
<select
|
||||
id="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="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="subagent_enabled">启用</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">关闭后仍保留 workspace 和配置</p>
|
||||
<Label htmlFor="subagent_enabled">{t('启用', 'Enabled')}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('关闭后仍保留 workspace 和配置', 'Turning this off keeps the workspace and config intact')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="subagent_enabled"
|
||||
@ -520,8 +547,8 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="subagent_allow_mcp">允许 MCP</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">保留 MCP 配置并在运行时接入</p>
|
||||
<Label htmlFor="subagent_allow_mcp">{t('允许 MCP', 'Allow MCP')}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('保留 MCP 配置并在运行时接入', 'Keep MCP config and attach it at runtime')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="subagent_allow_mcp"
|
||||
@ -532,7 +559,7 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_tags">标签</Label>
|
||||
<Label htmlFor="subagent_tags">{t('标签', 'Tags')}</Label>
|
||||
<Input
|
||||
id="subagent_tags"
|
||||
value={subagentForm.tags}
|
||||
@ -541,7 +568,7 @@ export default function AgentsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subagent_aliases">别名</Label>
|
||||
<Label htmlFor="subagent_aliases">{t('别名', 'Aliases')}</Label>
|
||||
<Input
|
||||
id="subagent_aliases"
|
||||
value={subagentForm.aliases}
|
||||
@ -553,7 +580,7 @@ export default function AgentsPage() {
|
||||
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<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' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
@ -579,19 +606,19 @@ export default function AgentsPage() {
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<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>
|
||||
和
|
||||
{t('和', 'and')}
|
||||
<code className="mx-1">AGENTS.md</code>
|
||||
,并注册到工作区智能体列表。
|
||||
{t(',并注册到工作区智能体列表。', 'and register it in the workspace agent registry.')}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
|
||||
取消
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={subagentSubmitting}>
|
||||
{subagentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : editingSubagentId ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{editingSubagentId ? '更新' : '创建'}
|
||||
{editingSubagentId ? t('更新', 'Update') : t('创建', 'Create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -613,8 +640,8 @@ export default function AgentsPage() {
|
||||
|
||||
<Tabs defaultValue="agents" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="agents">委派目标</TabsTrigger>
|
||||
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
|
||||
<TabsTrigger value="agents">{t('委派目标', 'Delegation targets')}</TabsTrigger>
|
||||
<TabsTrigger value="subagents">{t('Persistent Sub-Agents', 'Persistent sub-agents')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="agents" className="space-y-4">
|
||||
@ -630,25 +657,24 @@ export default function AgentsPage() {
|
||||
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{agent.description || '—'}
|
||||
{agent.description || t('暂无描述', 'No description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
||||
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
||||
{isManagedSubagent && <Badge className="bg-amber-600">受管 Sub-Agent</Badge>}
|
||||
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
||||
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
||||
<Badge variant="outline">{agentSourceLabel(agent.source, locale)}</Badge>
|
||||
<Badge variant="secondary">{agent.protocol || t('本地', 'Local')}</Badge>
|
||||
{isManagedSubagent && <Badge className="bg-amber-600">{t('受管 Sub-Agent', 'Managed sub-agent')}</Badge>}
|
||||
{agent.support_streaming && <Badge className="bg-sky-600">{t('流式', 'Streaming')}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
||||
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
||||
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
||||
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
||||
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
||||
{agent.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">{t('接口地址:', 'Endpoint:')}</span> {agent.endpoint}</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">{t('认证变量:', 'Auth env:')}</span> {agent.auth_env}</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_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
||||
</div>
|
||||
@ -664,7 +690,7 @@ export default function AgentsPage() {
|
||||
)}
|
||||
{agent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
<span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
|
||||
{agent.aliases.map((alias) => (
|
||||
<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">
|
||||
{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 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">只读来源</span>
|
||||
<span className="text-xs text-muted-foreground">{t('只读来源', 'Read-only source')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -694,12 +720,11 @@ export default function AgentsPage() {
|
||||
<TabsContent value="subagents" className="space-y-4">
|
||||
<Card className="border-border/70 bg-muted/20">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
|
||||
持久化 Sub-Agent 会在
|
||||
{t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')}
|
||||
<code className="mx-1">~/.nanobot/workspace/agents/<id>_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>
|
||||
,即只能向外委派到远端 A2A agent。
|
||||
{t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{subagent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{subagent.description || '—'}
|
||||
{subagent.description || t('暂无描述', 'No description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant={subagent.enabled ? 'default' : 'outline'}>
|
||||
{subagent.enabled ? '启用' : '停用'}
|
||||
{subagent.enabled ? t('启用', 'Enabled') : t('停用', 'Disabled')}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{subagent.delegation_mode}</Badge>
|
||||
{subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>}
|
||||
@ -726,11 +751,11 @@ export default function AgentsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
<div><span className="font-medium text-foreground">Workspace:</span> {subagent.workspace}</div>
|
||||
<div><span className="font-medium text-foreground">{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">RPC:</span> {subagent.endpoint}</div>
|
||||
<div><span className="font-medium text-foreground">Card:</span> {subagent.card_url}</div>
|
||||
<div><span className="font-medium text-foreground">MCP Servers:</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
|
||||
<div><span className="font-medium text-foreground">{t('MCP Servers:', 'MCP servers:')}</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
|
||||
</div>
|
||||
{subagent.system_prompt && (
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
@ -752,7 +777,7 @@ export default function AgentsPage() {
|
||||
)}
|
||||
{subagent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
<span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
|
||||
{subagent.aliases.map((alias) => (
|
||||
<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">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
{t('编辑', 'Edit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -777,7 +802,7 @@ export default function AgentsPage() {
|
||||
{subagents.length === 0 && (
|
||||
<Card>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -40,10 +40,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { CronJob } from '@/types';
|
||||
|
||||
export default function CronPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const sessionId = useChatStore((s) => s.sessionId);
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -58,7 +61,7 @@ export default function CronPage() {
|
||||
const data = await listCronJobs(true);
|
||||
setJobs(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载任务失败');
|
||||
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -138,16 +141,16 @@ export default function CronPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
定时任务
|
||||
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadJobs} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建任务
|
||||
{pickAppText(locale, '新建任务', 'New job')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -178,21 +181,21 @@ export default function CronPage() {
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无定时任务</p>
|
||||
<p className="text-sm mt-1">新建一个任务,让智能体按计划自动执行。</p>
|
||||
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">启用</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>计划</TableHead>
|
||||
<TableHead>消息</TableHead>
|
||||
<TableHead>上次运行</TableHead>
|
||||
<TableHead>下次运行</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -238,7 +241,7 @@ export default function CronPage() {
|
||||
)}
|
||||
{job.last_status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
错误
|
||||
{pickAppText(locale, '错误', 'Error')}
|
||||
</Badge>
|
||||
)}
|
||||
{!job.last_status && (
|
||||
@ -254,7 +257,7 @@ export default function CronPage() {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRun(job.id)}
|
||||
title="立即执行"
|
||||
title={pickAppText(locale, '立即执行', 'Run now')}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@ -263,7 +266,7 @@ export default function CronPage() {
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(job.id)}
|
||||
title="删除"
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@ -294,6 +297,7 @@ function AddJobForm({
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
|
||||
@ -317,7 +321,7 @@ function AddJobForm({
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<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}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@ -327,16 +331,16 @@ function AddJobForm({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">任务名称</Label>
|
||||
<Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:日报汇总"
|
||||
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-type">调度类型</Label>
|
||||
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
|
||||
<Select
|
||||
value={scheduleType}
|
||||
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
|
||||
@ -345,8 +349,8 @@ function AddJobForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every">固定间隔(每 N 秒)</SelectItem>
|
||||
<SelectItem value="cron">Cron 表达式</SelectItem>
|
||||
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
|
||||
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -354,7 +358,7 @@ function AddJobForm({
|
||||
|
||||
{scheduleType === 'every' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="every">间隔(秒)</Label>
|
||||
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
|
||||
<Input
|
||||
id="every"
|
||||
type="number"
|
||||
@ -365,15 +369,15 @@ function AddJobForm({
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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
|
||||
? `约 ${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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron">Cron 表达式</Label>
|
||||
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={cronExpr}
|
||||
@ -381,31 +385,31 @@ function AddJobForm({
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
格式:分钟 小时 日 月 周
|
||||
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">发送给智能体的消息</Label>
|
||||
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
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">
|
||||
任务结果会自动回写到当前 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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建任务
|
||||
{pickAppText(locale, '创建任务', 'Create job')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -29,8 +29,11 @@ import {
|
||||
import type { WorkspaceItem } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function FilesPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [items, setItems] = useState<WorkspaceItem[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -63,8 +66,14 @@ export default function FilesPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (item: WorkspaceItem) => {
|
||||
const label = item.type === 'directory' ? '文件夹' : '文件';
|
||||
if (!confirm(`确定删除${label} "${item.name}"?${item.type === 'directory' ? '(包含所有子文件)' : ''}`)) {
|
||||
const label = 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;
|
||||
}
|
||||
try {
|
||||
@ -144,7 +153,7 @@ export default function FilesPage() {
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', {
|
||||
return new Date(iso).toLocaleString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@ -159,7 +168,7 @@ export default function FilesPage() {
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -168,7 +177,7 @@ export default function FilesPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-1" />
|
||||
新建文件夹
|
||||
{pickAppText(locale, '新建文件夹', 'New folder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -184,7 +193,7 @@ export default function FilesPage() {
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
上传
|
||||
{pickAppText(locale, '上传', 'Upload')}
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
工作区
|
||||
{pickAppText(locale, '工作区', 'Workspace')}
|
||||
</button>
|
||||
{breadcrumbs.map((segment, idx) => {
|
||||
const path = breadcrumbs.slice(0, idx + 1).join('/');
|
||||
@ -251,12 +260,12 @@ export default function FilesPage() {
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateDir}>
|
||||
创建
|
||||
{pickAppText(locale, '创建', 'Create')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -266,7 +275,7 @@ export default function FilesPage() {
|
||||
setNewDirName('');
|
||||
}}
|
||||
>
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -279,8 +288,8 @@ export default function FilesPage() {
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">空文件夹</p>
|
||||
<p className="text-sm">点击上方"上传"或"新建文件夹"按钮开始使用</p>
|
||||
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
|
||||
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-14rem)]">
|
||||
@ -330,7 +339,7 @@ export default function FilesPage() {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleDownload(item)}
|
||||
title="下载"
|
||||
title={pickAppText(locale, '下载', 'Download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
@ -340,7 +349,7 @@ export default function FilesPage() {
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(item)}
|
||||
title="删除"
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import Header from '@/components/Header';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@ -10,7 +11,10 @@ export default function AppLayout({
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
<AuthGuard>
|
||||
<AppRuntimeBridge />
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -28,8 +28,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Marketplace, MarketplacePlugin } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
||||
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
|
||||
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
|
||||
@ -60,7 +63,7 @@ export default function MarketplacePage() {
|
||||
setPlugins([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载市场失败');
|
||||
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -72,7 +75,7 @@ export default function MarketplacePage() {
|
||||
const data = await listMarketplacePlugins(marketplaceName);
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载插件失败');
|
||||
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
|
||||
} finally {
|
||||
setPluginsLoading(false);
|
||||
}
|
||||
@ -99,7 +102,7 @@ export default function MarketplacePage() {
|
||||
await loadMarketplaces();
|
||||
setSelectedMarketplace(marketplace.name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '添加市场失败');
|
||||
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
@ -115,7 +118,7 @@ export default function MarketplacePage() {
|
||||
}
|
||||
await loadMarketplaces();
|
||||
} 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 loadPlugins(name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新市场失败');
|
||||
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
|
||||
} finally {
|
||||
setUpdatingMarketplace(null);
|
||||
}
|
||||
@ -139,7 +142,7 @@ export default function MarketplacePage() {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新插件失败');
|
||||
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
@ -152,7 +155,7 @@ export default function MarketplacePage() {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '安装插件失败');
|
||||
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
@ -167,7 +170,7 @@ export default function MarketplacePage() {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '卸载插件失败');
|
||||
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
@ -195,10 +198,10 @@ export default function MarketplacePage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Store className="w-6 h-6" />
|
||||
插件市场
|
||||
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
浏览并安装已注册市场中的插件
|
||||
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -208,11 +211,11 @@ export default function MarketplacePage() {
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加市场
|
||||
{pickAppText(locale, '添加市场', 'Add marketplace')}
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -245,7 +248,7 @@ export default function MarketplacePage() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
onChange={(e) => setAddSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@ -260,7 +263,7 @@ export default function MarketplacePage() {
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
添加
|
||||
{pickAppText(locale, '添加', 'Add')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@ -270,7 +273,7 @@ export default function MarketplacePage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -301,7 +304,7 @@ export default function MarketplacePage() {
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
|
||||
disabled={updatingMarketplace === marketplace.name}
|
||||
onClick={() => handleUpdateMarketplace(marketplace.name)}
|
||||
title="更新市场"
|
||||
title={pickAppText(locale, '更新市场', 'Update marketplace')}
|
||||
>
|
||||
{updatingMarketplace === marketplace.name ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@ -327,9 +330,9 @@ export default function MarketplacePage() {
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<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">
|
||||
点击上方的<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -346,8 +349,8 @@ export default function MarketplacePage() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无可用插件</p>
|
||||
<p className="text-sm mt-1">这个市场里暂时还没有插件。</p>
|
||||
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@ -364,7 +367,7 @@ export default function MarketplacePage() {
|
||||
{plugin.installed && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
已安装
|
||||
{pickAppText(locale, '已安装', 'Installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@ -390,7 +393,7 @@ export default function MarketplacePage() {
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
更新
|
||||
{pickAppText(locale, '更新', 'Update')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -403,7 +406,7 @@ export default function MarketplacePage() {
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
卸载
|
||||
{pickAppText(locale, '卸载', 'Uninstall')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -420,7 +423,7 @@ export default function MarketplacePage() {
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
安装
|
||||
{pickAppText(locale, '安装', 'Install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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';
|
||||
|
||||
@ -81,20 +84,22 @@ function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string
|
||||
};
|
||||
}
|
||||
|
||||
function serverStatusLabel(status?: string | null) {
|
||||
if (status === 'connected') return '已连接';
|
||||
if (status === 'error') return '异常';
|
||||
if (status === 'disconnected' || !status) return '未连接';
|
||||
function serverStatusLabel(status: string | null | undefined, locale: AppLocale) {
|
||||
if (status === 'connected') return pickAppText(locale, '已连接', 'Connected');
|
||||
if (status === 'error') return pickAppText(locale, '异常', 'Error');
|
||||
if (status === 'disconnected' || !status) return pickAppText(locale, '未连接', 'Disconnected');
|
||||
return status;
|
||||
}
|
||||
|
||||
function transportLabel(transport?: string) {
|
||||
if (transport === 'stdio') return '标准输入输出';
|
||||
function transportLabel(transport: string | undefined, locale: AppLocale) {
|
||||
if (transport === 'stdio') return pickAppText(locale, '标准输入输出', 'Standard I/O');
|
||||
if (transport === 'http') return 'HTTP';
|
||||
return transport || '-';
|
||||
}
|
||||
|
||||
export default function MCPPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
const cachedServers = useChatStore((s) => s.mcpRegistry);
|
||||
const cachedTools = useChatStore((s) => s.mcpToolRegistry);
|
||||
const setCachedServers = useChatStore((s) => s.setMcpRegistry);
|
||||
@ -134,7 +139,7 @@ export default function MCPPage() {
|
||||
setAuthzStatus(authzData);
|
||||
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载 MCP 服务失败');
|
||||
setError(err.message || t('加载 MCP 服务失败', 'Failed to load MCP servers'));
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
@ -172,7 +177,7 @@ export default function MCPPage() {
|
||||
if (!value.trim()) return {};
|
||||
const parsed = JSON.parse(value);
|
||||
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>;
|
||||
};
|
||||
@ -187,16 +192,16 @@ export default function MCPPage() {
|
||||
const command = form.command.trim();
|
||||
const toolTimeout = Number(form.tool_timeout || 30);
|
||||
if (!id) {
|
||||
throw new Error('ID 不能为空');
|
||||
throw new Error(t('ID 不能为空', 'ID cannot be empty'));
|
||||
}
|
||||
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) {
|
||||
throw new Error('请输入 MCP Server 地址');
|
||||
throw new Error(t('请输入 MCP Server 地址', 'Enter an MCP server URL'));
|
||||
}
|
||||
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';
|
||||
@ -209,7 +214,7 @@ export default function MCPPage() {
|
||||
: [],
|
||||
env: {},
|
||||
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_audience: authAudience,
|
||||
auth_scopes: [],
|
||||
@ -224,7 +229,7 @@ export default function MCPPage() {
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存 MCP 服务失败');
|
||||
setError(err.message || t('保存 MCP 服务失败', 'Failed to save the MCP server'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -236,7 +241,7 @@ export default function MCPPage() {
|
||||
setSelectedServerId((current) => (current === serverId ? null : current));
|
||||
await load();
|
||||
} 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 load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '测试 MCP 服务失败');
|
||||
setError(err.message || t('测试 MCP 服务失败', 'Failed to test the MCP server'));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
@ -257,20 +262,32 @@ export default function MCPPage() {
|
||||
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
|
||||
const selectedServer = selectedServerId ? servers.find((server) => 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 (!form.id.trim()) {
|
||||
authzHint = '先填写 MCP ID,Audience 会自动生成为 mcp:<id>。';
|
||||
authzHint = t('先填写 MCP ID,Audience 会自动生成为 mcp:<id>。', 'Enter the MCP ID first. The audience will become mcp:<id>.');
|
||||
} 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) {
|
||||
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) {
|
||||
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`;
|
||||
authzHint = t(`读取 AuthZ 权限失败:${authzStatus.error}`, `Failed to read AuthZ permissions: ${authzStatus.error}`);
|
||||
} 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 {
|
||||
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`;
|
||||
authzHint = t(`已从 AuthZ 读取到 ${authAudience} 的当前权限。`, `Loaded current permissions for ${authAudience} from AuthZ.`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,16 +305,16 @@ export default function MCPPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ServerCog className="w-6 h-6" />
|
||||
MCP 服务
|
||||
{t('MCP 服务', 'MCP servers')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理 MCP 服务配置、连通性和当前已发现的工具。
|
||||
{t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
@ -306,12 +323,12 @@ export default function MCPPage() {
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增 MCP
|
||||
{t('新增 MCP', 'Add MCP')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</DialogTitle>
|
||||
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<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} />
|
||||
</div>
|
||||
<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 }))} />
|
||||
</div>
|
||||
</div>
|
||||
@ -330,24 +347,24 @@ export default function MCPPage() {
|
||||
className="space-y-4"
|
||||
>
|
||||
<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">
|
||||
<TabsTrigger
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。
|
||||
{t('适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。', 'Use this for a remote MCP server that is already deployed. Provide the URL, headers, and auth settings.')}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。
|
||||
{t('适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。', 'Use this when the MCP process runs locally via `npx`, `uvx`, or another command.')}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</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">
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="url">MCP Server 地址</Label>
|
||||
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={form.url}
|
||||
@ -369,7 +386,7 @@ export default function MCPPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||
<Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
|
||||
<select
|
||||
id="auth_mode"
|
||||
value={form.auth_mode}
|
||||
@ -381,20 +398,20 @@ export default function MCPPage() {
|
||||
</select>
|
||||
</div>
|
||||
<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="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Audience</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Scopes</span>
|
||||
<span className="text-xs break-words">
|
||||
{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>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@ -404,7 +421,7 @@ export default function MCPPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="headers">请求头 JSON</Label>
|
||||
<Label htmlFor="headers">{t('请求头 JSON', 'Headers JSON')}</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
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">
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="command">命令</Label>
|
||||
<Label htmlFor="command">{t('命令', 'Command')}</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={form.command}
|
||||
@ -430,7 +447,7 @@ export default function MCPPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="args">参数</Label>
|
||||
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={form.args}
|
||||
@ -443,11 +460,11 @@ export default function MCPPage() {
|
||||
</Tabs>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
保存
|
||||
{t('保存', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -493,27 +510,27 @@ export default function MCPPage() {
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
|
||||
</div>
|
||||
<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'}>
|
||||
{serverStatusLabel(server.status)}
|
||||
{serverStatusLabel(server.status, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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.command && <div><span className="font-medium">命令:</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.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">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
|
||||
{(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>
|
||||
)}
|
||||
{(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) && (
|
||||
<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">
|
||||
<span>{server.tool_count || 0} 个工具</span>
|
||||
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</span>
|
||||
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</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>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@ -521,21 +538,21 @@ export default function MCPPage() {
|
||||
event.stopPropagation();
|
||||
openEdit(server);
|
||||
}}>
|
||||
编辑
|
||||
{t('编辑', 'Edit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleTest(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" />}
|
||||
测试
|
||||
{t('测试', 'Test')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(server.id);
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -544,7 +561,7 @@ export default function MCPPage() {
|
||||
{servers.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
暂无 MCP 服务。
|
||||
{t('暂无 MCP 服务。', 'There are no MCP servers yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -554,17 +571,17 @@ export default function MCPPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'}
|
||||
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedServer && (
|
||||
<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>
|
||||
)}
|
||||
{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 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -34,8 +34,84 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
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';
|
||||
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
@ -87,6 +163,7 @@ function BoardPanel({
|
||||
}
|
||||
|
||||
export default function OfficeDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
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 office = React.useMemo(
|
||||
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }),
|
||||
[processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
);
|
||||
const metadataLabels = React.useMemo(() => traceMetadataLabels(locale), [locale]);
|
||||
|
||||
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
|
||||
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, 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(
|
||||
() => processEvents
|
||||
.filter((event) => event.run_id === selectedTask?.runId)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 8),
|
||||
.slice(0, 16),
|
||||
[processEvents, selectedTask?.runId]
|
||||
);
|
||||
|
||||
@ -139,14 +225,18 @@ export default function OfficeDetailPage() {
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/office">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回 Office 列表
|
||||
{pickAppText(locale, '返回 Office 列表', 'Back to office list')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<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">
|
||||
当前 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -164,13 +254,13 @@ export default function OfficeDetailPage() {
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/office">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回 Office
|
||||
{pickAppText(locale, '返回 Office', 'Back to office')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
回到对话
|
||||
{pickAppText(locale, '回到对话', 'Back to chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@ -186,18 +276,18 @@ export default function OfficeDetailPage() {
|
||||
<OfficeStatusBadge status={office.status} className="bg-black/20" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||
<span>Lead: {office.rootActorName}</span>
|
||||
<span>Session: {office.sourceSessionLabel}</span>
|
||||
<span>Started: {formatOfficeTime(office.createdAt)}</span>
|
||||
<span>Duration: {formatOfficeDuration(office.durationMs)}</span>
|
||||
<span>{pickAppText(locale, '负责人', 'Lead')}: {office.rootActorName}</span>
|
||||
<span>{pickAppText(locale, '会话', 'Session')}: {office.sourceSessionLabel}</span>
|
||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(office.createdAt, locale)}</span>
|
||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(office.durationMs, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
|
||||
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} />
|
||||
<MetricTile label="参与成员" value={String(office.stats.memberCount)} />
|
||||
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} />
|
||||
<MetricTile label="告警数量" value={String(office.alerts.length)} />
|
||||
<MetricTile label={pickAppText(locale, '运行实例', 'Runs')} value={String(office.stats.totalRuns)} />
|
||||
<MetricTile label={pickAppText(locale, '参与成员', 'Members')} value={String(office.stats.memberCount)} />
|
||||
<MetricTile label={pickAppText(locale, '产物数量', 'Artifacts')} value={String(office.stats.artifactCount)} />
|
||||
<MetricTile label={pickAppText(locale, '告警数量', 'Alerts')} value={String(office.alerts.length)} />
|
||||
</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]">
|
||||
<PixelPanel
|
||||
title="昨日小记"
|
||||
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。"
|
||||
title={pickAppText(locale, '昨日小记', 'Yesterday notes')}
|
||||
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="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>
|
||||
{office.alerts.slice(0, 2).map((alert) => (
|
||||
<button
|
||||
@ -236,13 +326,13 @@ export default function OfficeDetailPage() {
|
||||
</PixelPanel>
|
||||
|
||||
<PixelPanel
|
||||
title="任务控制台"
|
||||
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。"
|
||||
title={pickAppText(locale, '任务控制台', 'Task console')}
|
||||
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="grid gap-3 sm:grid-cols-2">
|
||||
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
|
||||
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} />
|
||||
<MiniMetric label={pickAppText(locale, '当前阶段', 'Current stage')} value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
|
||||
<MiniMetric label={pickAppText(locale, '活跃实例', 'Active runs')} value={String(office.stats.activeRuns)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -260,10 +350,10 @@ export default function OfficeDetailPage() {
|
||||
|
||||
{selectedTask ? (
|
||||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">当前聚焦</div>
|
||||
<div className="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-1 text-xs text-slate-400">
|
||||
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'}
|
||||
{selectedTask.actorName} · {selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -273,7 +363,7 @@ export default function OfficeDetailPage() {
|
||||
onClick={() => setDetailOpen(true)}
|
||||
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" />
|
||||
</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]"
|
||||
>
|
||||
<Link href="/">
|
||||
回到对话
|
||||
{pickAppText(locale, '回到对话', 'Back to chat')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@ -290,15 +380,15 @@ export default function OfficeDetailPage() {
|
||||
|
||||
{isOfficeTaskTerminal(office.status) ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</PixelPanel>
|
||||
|
||||
<PixelPanel
|
||||
title="办公人员名单"
|
||||
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。"
|
||||
title={pickAppText(locale, '办公人员名单', 'Roster')}
|
||||
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">
|
||||
{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]">
|
||||
<BoardPanel
|
||||
icon={ListTree}
|
||||
title="任务看板"
|
||||
description="当前 task 下所有 run 的结构化列表。"
|
||||
title={pickAppText(locale, '任务看板', 'Task board')}
|
||||
description={pickAppText(locale, '当前 task 下所有 run 的结构化列表。', 'Structured list of all runs under this task.')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{office.tasks.map((task) => (
|
||||
@ -349,8 +439,8 @@ export default function OfficeDetailPage() {
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
|
||||
<span>{task.actorName}</span>
|
||||
<span>{formatOfficeTime(task.updatedAt)}</span>
|
||||
<span>{task.artifactCount} 个产物</span>
|
||||
<span>{formatOfficeTime(task.updatedAt, locale)}</span>
|
||||
<span>{pickAppText(locale, `${task.artifactCount} 个产物`, `${task.artifactCount} artifacts`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={task.status} />
|
||||
@ -363,13 +453,13 @@ export default function OfficeDetailPage() {
|
||||
<div className="space-y-5">
|
||||
<BoardPanel
|
||||
icon={Boxes}
|
||||
title="分工关系"
|
||||
description="主 Agent 到子 Agent 的委派关系。"
|
||||
title={pickAppText(locale, '分工关系', 'Assignments')}
|
||||
description={pickAppText(locale, '主 Agent 到子 Agent 的委派关系。', 'Delegation links from the lead agent to sub-agents.')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{office.assignments.length === 0 ? (
|
||||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||||
当前没有可见的子任务分工。
|
||||
{pickAppText(locale, '当前没有可见的子任务分工。', 'No visible subtask assignments yet.')}
|
||||
</div>
|
||||
) : (
|
||||
office.assignments.map((assignment) => (
|
||||
@ -389,13 +479,13 @@ export default function OfficeDetailPage() {
|
||||
|
||||
<BoardPanel
|
||||
icon={Siren}
|
||||
title="现场告警"
|
||||
description="优先展示失败、阻塞和较高风险的任务信号。"
|
||||
title={pickAppText(locale, '现场告警', 'Live alerts')}
|
||||
description={pickAppText(locale, '优先展示失败、阻塞和较高风险的任务信号。', 'Prioritize failed, blocked, and higher-risk task signals.')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{office.alerts.length === 0 ? (
|
||||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||||
当前没有高优先级告警。
|
||||
{pickAppText(locale, '当前没有高优先级告警。', 'There are no high-priority alerts right now.')}
|
||||
</div>
|
||||
) : (
|
||||
office.alerts.map((alert) => (
|
||||
@ -420,17 +510,17 @@ export default function OfficeDetailPage() {
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
|
||||
<SheetHeader className="pr-8">
|
||||
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle>
|
||||
<SheetTitle>{selectedTask?.title ?? pickAppText(locale, '任务详情', 'Task details')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedTask
|
||||
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}`
|
||||
: '当前没有选中的任务实例。'}
|
||||
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}`
|
||||
: pickAppText(locale, '当前没有选中的任务实例。', 'No task run is currently selected.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!selectedTask ? (
|
||||
<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>
|
||||
) : (
|
||||
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
|
||||
@ -445,18 +535,31 @@ export default function OfficeDetailPage() {
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">开始时间</span>
|
||||
<span>{formatOfficeTime(selectedTask.startedAt)}</span>
|
||||
<span className="text-muted-foreground">{pickAppText(locale, '开始时间', 'Started')}</span>
|
||||
<span>{formatOfficeTime(selectedTask.startedAt, locale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">最近更新</span>
|
||||
<span>{formatOfficeTime(selectedTask.updatedAt)}</span>
|
||||
<span className="text-muted-foreground">{pickAppText(locale, '最近更新', 'Last update')}</span>
|
||||
<span>{formatOfficeTime(selectedTask.updatedAt, locale)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</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 ? (
|
||||
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
|
||||
{selectedTask.summary}
|
||||
@ -466,16 +569,16 @@ export default function OfficeDetailPage() {
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">产物</div>
|
||||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '产物', 'Artifacts')}</div>
|
||||
<div className="space-y-2 p-4">
|
||||
{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) => (
|
||||
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
|
||||
<div className="font-medium">{artifact.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)}
|
||||
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@ -484,22 +587,40 @@ export default function OfficeDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">最近事件</div>
|
||||
<div className="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">
|
||||
{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 className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div>
|
||||
<div 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, locale)}</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">
|
||||
{event.text || '结构化更新'}
|
||||
{event.text || pickAppText(locale, '结构化更新', 'Structured update')}
|
||||
</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>
|
||||
|
||||
@ -17,6 +17,9 @@ import { TaskManagementTabs } from '@/components/task-management/TaskManagementT
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
function TaskCard({
|
||||
@ -33,6 +36,7 @@ function TaskCard({
|
||||
currentStageLabel,
|
||||
progressLabel,
|
||||
progressValue,
|
||||
locale,
|
||||
}: {
|
||||
taskId: string;
|
||||
title: string;
|
||||
@ -47,6 +51,7 @@ function TaskCard({
|
||||
currentStageLabel: string | null;
|
||||
progressLabel: string;
|
||||
progressValue: number;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/80 transition-colors hover:border-primary/30">
|
||||
@ -55,9 +60,9 @@ function TaskCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-lg">{title}</CardTitle>
|
||||
<CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>会话: {sessionLabel}</span>
|
||||
<span>主 Agent: {rootActorName}</span>
|
||||
<span>更新于 {formatOfficeTime(updatedAt)}</span>
|
||||
<span>{pickAppText(locale, '会话', 'Session')}: {sessionLabel}</span>
|
||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {rootActorName}</span>
|
||||
<span>{pickAppText(locale, '更新于', 'Updated')} {formatOfficeTime(updatedAt, locale)}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<OfficeStatusBadge status={status} />
|
||||
@ -65,10 +70,10 @@ function TaskCard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Metric icon={Users} label="成员" value={String(memberCount)} />
|
||||
<Metric icon={Activity} label="活跃" value={String(activeRuns)} />
|
||||
<Metric icon={FolderKanban} label="产物" value={String(artifactCount)} />
|
||||
<Metric icon={Sparkles} label="异常" value={String(errorCount)} />
|
||||
<Metric icon={Users} label={pickAppText(locale, '成员', 'Members')} value={String(memberCount)} />
|
||||
<Metric icon={Activity} label={pickAppText(locale, '活跃', 'Active')} value={String(activeRuns)} />
|
||||
<Metric icon={FolderKanban} label={pickAppText(locale, '产物', 'Artifacts')} value={String(artifactCount)} />
|
||||
<Metric icon={Sparkles} label={pickAppText(locale, '异常', 'Alerts')} value={String(errorCount)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -87,7 +92,7 @@ function TaskCard({
|
||||
<div className="flex justify-end">
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/office/${encodeURIComponent(taskId)}`}>
|
||||
进入办公室
|
||||
{pickAppText(locale, '进入办公室', 'Open office')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@ -118,6 +123,7 @@ function Metric({
|
||||
}
|
||||
|
||||
export default function OfficeListPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const sessionId = useChatStore((state) => state.sessionId);
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
@ -132,8 +138,8 @@ export default function OfficeListPage() {
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
}),
|
||||
[processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
}, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
);
|
||||
|
||||
const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status));
|
||||
@ -147,18 +153,22 @@ export default function OfficeListPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Office</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||||
基于当前会话的真实运行数据,展示主 Agent 与子 Agent 的任务现场。任务结束后会从活跃现场移除,但保留回看入口。
|
||||
{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>
|
||||
</div>
|
||||
<Card className="min-w-[280px] border-border/70">
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">当前会话</div>
|
||||
<div className="text-xs text-muted-foreground">{pickAppText(locale, '当前会话', 'Current session')}</div>
|
||||
<div className="mt-1 font-medium">{sessionId}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">连接状态</div>
|
||||
<div className="mt-1 font-medium">{wsStatus === 'connected' ? '已连接' : wsStatus}</div>
|
||||
<div className="text-xs text-muted-foreground">{pickAppText(locale, '连接状态', 'Connection')}</div>
|
||||
<div className="mt-1 font-medium">{appConnectionStatusLabel(wsStatus, wsStatus === 'connected' ? true : null, locale)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -167,7 +177,7 @@ export default function OfficeListPage() {
|
||||
{wsStatus === 'connecting' && tasks.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在等待运行时数据...
|
||||
{pickAppText(locale, '正在等待运行时数据...', 'Waiting for runtime data...')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -175,12 +185,16 @@ export default function OfficeListPage() {
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Clock3 className="h-10 w-10 text-muted-foreground/50" />
|
||||
<h2 className="mt-4 text-xl font-semibold">当前没有可展示的任务现场</h2>
|
||||
<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">
|
||||
先回到对话页发起一次主 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>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href="/">回到对话</Link>
|
||||
<Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -189,15 +203,15 @@ export default function OfficeListPage() {
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">活跃 Office</h2>
|
||||
<p className="text-sm text-muted-foreground">正在运行中的任务现场会优先显示。</p>
|
||||
<h2 className="text-xl font-semibold">{pickAppText(locale, '活跃 Office', 'Active office')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '正在运行中的任务现场会优先显示。', 'Running task floors are shown first.')}</p>
|
||||
</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>
|
||||
{activeTasks.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<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>
|
||||
</Card>
|
||||
) : (
|
||||
@ -218,6 +232,7 @@ export default function OfficeListPage() {
|
||||
currentStageLabel={task.currentStageLabel}
|
||||
progressLabel={task.progress.label}
|
||||
progressValue={progressPercent(task.progress.value, task.progress.max)}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -227,15 +242,15 @@ export default function OfficeListPage() {
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">最近结束</h2>
|
||||
<p className="text-sm text-muted-foreground">已完成、失败或取消的任务仍保留回看入口。</p>
|
||||
<h2 className="text-xl font-semibold">{pickAppText(locale, '最近结束', 'Recently finished')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '已完成、失败或取消的任务仍保留回看入口。', 'Completed, failed, or cancelled tasks remain available for review.')}</p>
|
||||
</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>
|
||||
{recentTasks.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
还没有历史任务。
|
||||
{pickAppText(locale, '还没有历史任务。', 'There is no task history yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@ -256,6 +271,7 @@ export default function OfficeListPage() {
|
||||
currentStageLabel={task.currentStageLabel}
|
||||
progressLabel={task.progress.label}
|
||||
progressValue={progressPercent(task.progress.value, task.progress.max)}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -54,6 +54,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 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 '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@ -115,19 +118,19 @@ function formatDateKey(value?: string | null): string | null {
|
||||
return toLocalDateKey(date);
|
||||
}
|
||||
|
||||
function formatDayLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
function formatDayLabel(value: Date, locale: AppLocale = 'zh-CN'): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null): string {
|
||||
function formatTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
@ -241,9 +244,9 @@ function sanitizeEmailHtml(html: string): string {
|
||||
return documentRef.body.innerHTML;
|
||||
}
|
||||
|
||||
function buildEmailPreviewDocument(html: string): string {
|
||||
function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="${locale}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@ -334,6 +337,8 @@ function renderPlainText(content: string): React.ReactNode[] {
|
||||
}
|
||||
|
||||
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 [form, setForm] = useState<OutlookFormState>(EMPTY_FORM);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
@ -382,11 +387,11 @@ export default function OutlookPage() {
|
||||
if (!preserveExisting) {
|
||||
setOverview(null);
|
||||
}
|
||||
setError(err.message || '加载 Outlook 概览失败');
|
||||
setError(err.message || t('加载 Outlook 概览失败', 'Failed to load the Outlook overview'));
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: true }));
|
||||
@ -402,11 +407,17 @@ export default function OutlookPage() {
|
||||
}
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
|
||||
setError(
|
||||
err.message
|
||||
|| t(
|
||||
`加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`,
|
||||
`Failed to load the ${view === 'inbox' ? 'inbox' : 'sent mailbox'}`
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadCalendarPage = useCallback(async (anchorKey: string) => {
|
||||
setCalendarLoading(true);
|
||||
@ -420,11 +431,11 @@ export default function OutlookPage() {
|
||||
setCalendarPage(nextPage);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载日程失败');
|
||||
setError(err.message || t('加载日程失败', 'Failed to load calendar events'));
|
||||
} finally {
|
||||
setCalendarLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadStatus = useCallback(async (
|
||||
background = false,
|
||||
@ -452,7 +463,7 @@ export default function OutlookPage() {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载 Outlook 集成状态失败');
|
||||
setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status'));
|
||||
setOverviewLoading(false);
|
||||
} finally {
|
||||
if (background) {
|
||||
@ -461,7 +472,7 @@ export default function OutlookPage() {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
}
|
||||
}, [applyStatus, loadOverview]);
|
||||
}, [applyStatus, loadOverview, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
@ -483,7 +494,7 @@ export default function OutlookPage() {
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
setError(err.message || '加载邮件详情失败');
|
||||
setError(err.message || t('加载邮件详情失败', 'Failed to load message details'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@ -495,7 +506,7 @@ export default function OutlookPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedMessageRef]);
|
||||
}, [selectedMessageRef, t]);
|
||||
|
||||
const canTest = useMemo(
|
||||
() => Boolean(
|
||||
@ -519,8 +530,8 @@ export default function OutlookPage() {
|
||||
return [
|
||||
{
|
||||
id: 'settings' as const,
|
||||
label: '设置',
|
||||
hint: '配置 Outlook 连接',
|
||||
label: t('设置', 'Settings'),
|
||||
hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
|
||||
icon: Settings2,
|
||||
count: null,
|
||||
},
|
||||
@ -530,34 +541,34 @@ export default function OutlookPage() {
|
||||
return [
|
||||
{
|
||||
id: 'inbox' as const,
|
||||
label: '收件箱',
|
||||
hint: '最近接收邮件',
|
||||
label: t('收件箱', 'Inbox'),
|
||||
hint: t('最近接收邮件', 'Recently received mail'),
|
||||
icon: Inbox,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'sent' as const,
|
||||
label: '发件箱',
|
||||
hint: '最近发送记录',
|
||||
label: t('发件箱', 'Sent'),
|
||||
hint: t('最近发送记录', 'Recently sent messages'),
|
||||
icon: Send,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'calendar' as const,
|
||||
label: '日程',
|
||||
hint: '未来 7 天',
|
||||
label: t('日程', 'Calendar'),
|
||||
hint: t('未来 7 天', 'Next 7 days'),
|
||||
icon: CalendarDays,
|
||||
count: overviewPending ? null : eventCount,
|
||||
},
|
||||
{
|
||||
id: 'settings' as const,
|
||||
label: '设置',
|
||||
hint: '连接与状态',
|
||||
label: t('设置', 'Settings'),
|
||||
hint: t('连接与状态', 'Connection and status'),
|
||||
icon: Settings2,
|
||||
count: null,
|
||||
},
|
||||
];
|
||||
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount]);
|
||||
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableViews.some((view) => view.id === activeView)) {
|
||||
@ -604,7 +615,7 @@ export default function OutlookPage() {
|
||||
const result = await testOutlookConnection(form);
|
||||
setTestResult(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '测试连接失败');
|
||||
setError(err.message || t('测试连接失败', 'Failed to test the connection'));
|
||||
setTestResult(null);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@ -626,7 +637,7 @@ export default function OutlookPage() {
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
setActiveView('inbox');
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存 Outlook 配置失败');
|
||||
setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -649,7 +660,7 @@ export default function OutlookPage() {
|
||||
setFormDirty(false);
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
} catch (err: any) {
|
||||
setError(err.message || '断开 Outlook 连接失败');
|
||||
setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook'));
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
@ -688,16 +699,16 @@ export default function OutlookPage() {
|
||||
) : (
|
||||
<>
|
||||
<Badge variant={statusVariant(isConnected)}>
|
||||
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
|
||||
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
|
||||
</Badge>
|
||||
<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 variant="secondary">{status?.provider || 'ews'}</Badge>
|
||||
<span className="text-muted-foreground">邮箱 {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('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
|
||||
<span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@ -706,14 +717,14 @@ export default function OutlookPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<TopStat label="收件箱" value={String(inboxCount)} loading={overviewPending} />
|
||||
<TopStat label="发件箱" value={String(sentCount)} loading={overviewPending} />
|
||||
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
|
||||
<TopStat label={t('收件箱', 'Inbox')} value={String(inboxCount)} loading={overviewPending} />
|
||||
<TopStat label={t('发件箱', 'Sent')} value={String(sentCount)} loading={overviewPending} />
|
||||
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -765,7 +776,7 @@ export default function OutlookPage() {
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-semibold">{view.label}</p>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
@ -777,12 +788,13 @@ export default function OutlookPage() {
|
||||
|
||||
<TabsContent value="inbox" className="mt-0">
|
||||
<MessageCard
|
||||
title="收件箱"
|
||||
title={t('收件箱', 'Inbox')}
|
||||
icon={<MailOpen className="h-4 w-4" />}
|
||||
items={inboxPage?.value || []}
|
||||
page={inboxPage?.page || null}
|
||||
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)}
|
||||
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.inbox}
|
||||
@ -798,12 +810,13 @@ export default function OutlookPage() {
|
||||
|
||||
<TabsContent value="sent" className="mt-0">
|
||||
<MessageCard
|
||||
title="发件箱"
|
||||
title={t('发件箱', 'Sent')}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
items={sentPage?.value || []}
|
||||
page={sentPage?.page || null}
|
||||
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)}
|
||||
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.sent}
|
||||
@ -822,6 +835,7 @@ export default function OutlookPage() {
|
||||
items={calendarPage?.value || []}
|
||||
startDate={calendarAnchorKey}
|
||||
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
|
||||
locale={locale}
|
||||
onOpen={(item) => setSelectedEvent(item)}
|
||||
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
|
||||
refreshing={calendarLoading}
|
||||
@ -849,33 +863,33 @@ export default function OutlookPage() {
|
||||
<div className="grid gap-6 xl:grid-cols-[1.08fr,0.92fr]">
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
<CardHeader className="border-b pb-5">
|
||||
<CardTitle className="text-xl text-foreground">连接设置</CardTitle>
|
||||
<CardTitle className="text-xl text-foreground">{t('连接设置', 'Connection settings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="邮箱地址" required>
|
||||
<Field label={t('邮箱地址', 'Email address')} required>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder="you@boardware.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="用户名">
|
||||
<Field label={t('用户名', 'Username')}>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(event) => updateField('username', event.target.value)}
|
||||
placeholder="留空时默认取邮箱前缀"
|
||||
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="密码" required>
|
||||
<Field label={t('密码', 'Password')} required>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
placeholder="请输入邮箱密码"
|
||||
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="域">
|
||||
<Field label={t('域', 'Domain')}>
|
||||
<Input
|
||||
value={form.domain}
|
||||
onChange={(event) => updateField('domain', event.target.value)}
|
||||
@ -898,7 +912,7 @@ export default function OutlookPage() {
|
||||
disabled={form.autodiscover}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="时区">
|
||||
<Field label={t('时区', 'Timezone')}>
|
||||
<Input
|
||||
value={form.default_timezone}
|
||||
onChange={(event) => updateField('default_timezone', event.target.value)}
|
||||
@ -912,7 +926,7 @@ export default function OutlookPage() {
|
||||
Autodiscover
|
||||
</Label>
|
||||
<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>
|
||||
</div>
|
||||
<Switch
|
||||
@ -927,11 +941,11 @@ export default function OutlookPage() {
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<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" />}
|
||||
测试连接
|
||||
{t('测试连接', 'Test connection')}
|
||||
</Button>
|
||||
<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" />}
|
||||
保存并启用
|
||||
{t('保存并启用', 'Save and enable')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -939,21 +953,21 @@ export default function OutlookPage() {
|
||||
disabled={!status?.configured || disconnecting}
|
||||
>
|
||||
{disconnecting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unplug className="mr-2 h-4 w-4" />}
|
||||
断开连接
|
||||
{t('断开连接', 'Disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
|
||||
<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.resolved_username}</span>
|
||||
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<MiniStat label="检测到文件夹" value={String(testResult.sample.folders.length)} />
|
||||
<MiniStat label="收件箱样本" value={String(testResult.sample.inbox.length)} />
|
||||
<MiniStat label="日程样本" value={String(testResult.sample.events.length)} />
|
||||
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
|
||||
<MiniStat label={t('收件箱样本', 'Inbox samples')} value={String(testResult.sample.inbox.length)} />
|
||||
<MiniStat label={t('日程样本', 'Calendar samples')} value={String(testResult.sample.events.length)} />
|
||||
</div>
|
||||
{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">
|
||||
@ -972,7 +986,7 @@ export default function OutlookPage() {
|
||||
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
<CardHeader className="border-b pb-5">
|
||||
<CardTitle className="text-xl text-foreground">连接状态</CardTitle>
|
||||
<CardTitle className="text-xl text-foreground">{t('连接状态', 'Connection status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -985,27 +999,27 @@ export default function OutlookPage() {
|
||||
) : (
|
||||
<>
|
||||
<Badge variant={statusVariant(isConnected)}>
|
||||
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
|
||||
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
|
||||
</Badge>
|
||||
<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 variant="secondary">{status?.provider || 'ews'}</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InfoRow label="邮箱" value={status?.saved?.email || '-'} loading={statusPending} />
|
||||
<InfoRow label="用户名" value={status?.saved?.username || '-'} loading={statusPending} />
|
||||
<InfoRow label="域" value={status?.saved?.domain || '-'} loading={statusPending} />
|
||||
<InfoRow label={t('邮箱', 'Email')} value={status?.saved?.email || '-'} loading={statusPending} />
|
||||
<InfoRow label={t('用户名', 'Username')} value={status?.saved?.username || '-'} 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="Server Host" value={status?.saved?.server || '-'} loading={statusPending} />
|
||||
<InfoRow label="时区" 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="最近接入" value={formatDateTime(status?.meta?.last_connected_at as string | undefined)} loading={statusPending} />
|
||||
<InfoRow label={t('时区', 'Timezone')} value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
|
||||
<InfoRow label={t('最近验证', 'Last verified')} value={formatDateTime(status?.meta?.last_verified_at as string | undefined, locale)} loading={statusPending} />
|
||||
<InfoRow label={t('最近接入', 'Last connected')} value={formatDateTime(status?.meta?.last_connected_at as string | undefined, locale)} loading={statusPending} />
|
||||
<InfoRow
|
||||
label="最近刷新"
|
||||
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
|
||||
label={t('最近刷新', 'Last refreshed')}
|
||||
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
|
||||
loading={statusPending || overviewPending}
|
||||
/>
|
||||
|
||||
@ -1017,12 +1031,15 @@ export default function OutlookPage() {
|
||||
|
||||
<div className="rounded-3xl border bg-muted/30 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
当前存储位置
|
||||
{t('当前存储位置', 'Current storage mode')}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-foreground">
|
||||
{status?.storage_mode === 'authz'
|
||||
? '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service,由外置 Outlook MCP 按 backend 身份读取。'
|
||||
: <>当前为 workspace 模式。Outlook 状态文件会写入当前 workspace 的 <code>state/bw_outlook_mcp</code>。</>}
|
||||
? t(
|
||||
'当前为 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -1034,9 +1051,9 @@ export default function OutlookPage() {
|
||||
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedMessage?.subject || '邮件详情'}</DialogTitle>
|
||||
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime) : '正在加载'}
|
||||
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{messageLoading ? (
|
||||
@ -1046,32 +1063,33 @@ export default function OutlookPage() {
|
||||
) : selectedMessage ? (
|
||||
<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">
|
||||
<InfoRow label="发件人" value={mailboxLabel(selectedMessage.from)} />
|
||||
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
|
||||
<InfoRow
|
||||
label="收件人"
|
||||
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(';') || '-'}
|
||||
label={t('收件人', 'To')}
|
||||
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : ';') || '-'}
|
||||
/>
|
||||
<InfoRow
|
||||
label="抄送"
|
||||
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join(';') || '-'}
|
||||
label={t('抄送', 'Cc')}
|
||||
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">
|
||||
<Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}>
|
||||
{selectedMessage.isRead ? '已读' : '未读'}
|
||||
{selectedMessage.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
正文
|
||||
{t('正文', 'Body')}
|
||||
</div>
|
||||
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (
|
||||
<iframe
|
||||
title="邮件正文"
|
||||
title={t('邮件正文', 'Message body')}
|
||||
srcDoc={buildEmailPreviewDocument(
|
||||
selectedMessage.body?.content || selectedMessage.bodyPreview || ''
|
||||
selectedMessage.body?.content || selectedMessage.bodyPreview || '',
|
||||
locale
|
||||
)}
|
||||
className="h-[60vh] w-full bg-white"
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
@ -1086,7 +1104,7 @@ export default function OutlookPage() {
|
||||
</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>
|
||||
</Dialog>
|
||||
@ -1094,26 +1112,26 @@ export default function OutlookPage() {
|
||||
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEvent?.subject || '日程详情'}</DialogTitle>
|
||||
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedEvent
|
||||
? `${formatDateTime(selectedEvent.start?.dateTime)} - ${formatDateTime(selectedEvent.end?.dateTime)}`
|
||||
: '日程详情'}
|
||||
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
|
||||
: t('日程详情', 'Event details')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<InfoRow label="组织者" value={mailboxLabel(selectedEvent.organizer)} />
|
||||
<InfoRow label="地点" value={selectedEvent.location?.displayName || '-'} />
|
||||
<InfoRow label={t('组织者', 'Organizer')} value={mailboxLabel(selectedEvent.organizer)} />
|
||||
<InfoRow label={t('地点', 'Location')} value={selectedEvent.location?.displayName || '-'} />
|
||||
<InfoRow
|
||||
label="参会人"
|
||||
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join(';') || '-'}
|
||||
label={t('参会人', 'Attendees')}
|
||||
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : ';') || '-'}
|
||||
/>
|
||||
<Separator />
|
||||
<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">
|
||||
{selectedEvent.bodyPreview || '没有更多说明。'}
|
||||
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1185,6 +1203,7 @@ function MessageCard({
|
||||
icon,
|
||||
items,
|
||||
page,
|
||||
locale,
|
||||
loading = false,
|
||||
emptyLabel,
|
||||
onOpen,
|
||||
@ -1197,6 +1216,7 @@ function MessageCard({
|
||||
icon: React.ReactNode;
|
||||
items: OutlookMessageSummary[];
|
||||
page: OutlookPageInfo | null;
|
||||
locale: AppLocale;
|
||||
loading?: boolean;
|
||||
emptyLabel: string;
|
||||
onOpen: (item: OutlookMessageSummary) => void;
|
||||
@ -1205,8 +1225,9 @@ function MessageCard({
|
||||
onPreviousPage: () => 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 pageLabel = page ? `第 ${currentPage} 页 · 本页 ${page.returned} 封` : '正在读取邮件…';
|
||||
const pageLabel = page ? t(`第 ${currentPage} 页 · 本页 ${page.returned} 封`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
|
||||
|
||||
return (
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
@ -1216,14 +1237,14 @@ function MessageCard({
|
||||
{icon}
|
||||
{title}
|
||||
</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 className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||
上一页
|
||||
{t('上一页', 'Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -1231,7 +1252,7 @@ function MessageCard({
|
||||
onClick={onNextPage}
|
||||
disabled={!page || !page.has_more || refreshing}
|
||||
>
|
||||
下一页
|
||||
{t('下一页', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</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="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-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||
{item.bodyPreview || '没有预览内容。'}
|
||||
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
|
||||
<Badge variant={item.isRead ? 'secondary' : 'default'}>
|
||||
{item.isRead ? '已读' : '未读'}
|
||||
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
|
||||
</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>
|
||||
</button>
|
||||
@ -1287,6 +1308,7 @@ function MessageCard({
|
||||
function EventCard({
|
||||
items,
|
||||
startDate,
|
||||
locale,
|
||||
loading = false,
|
||||
onOpen,
|
||||
onRefresh,
|
||||
@ -1297,6 +1319,7 @@ function EventCard({
|
||||
}: {
|
||||
items: OutlookEventSummary[];
|
||||
startDate?: string | null;
|
||||
locale: AppLocale;
|
||||
loading?: boolean;
|
||||
onOpen: (item: OutlookEventSummary) => void;
|
||||
onRefresh: () => void;
|
||||
@ -1305,6 +1328,7 @@ function EventCard({
|
||||
onNextWeek: () => void;
|
||||
onCurrentWeek: () => void;
|
||||
}) {
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
const initialAnchor = startDate ? new Date(startDate) : new Date();
|
||||
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
|
||||
const weekDays = Array.from({ length: 7 }, (_, index) => {
|
||||
@ -1316,7 +1340,7 @@ function EventCard({
|
||||
const key = toLocalDateKey(day);
|
||||
return {
|
||||
key,
|
||||
label: formatDayLabel(day),
|
||||
label: formatDayLabel(day, locale),
|
||||
items: items
|
||||
.filter((item) => formatDateKey(item.start?.dateTime) === key)
|
||||
.sort((left, right) => {
|
||||
@ -1333,21 +1357,21 @@ function EventCard({
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
日程安排
|
||||
{t('日程安排', 'Schedule')}
|
||||
</CardTitle>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
|
||||
上一周
|
||||
{t('上一周', 'Previous week')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
|
||||
本周
|
||||
{t('本周', 'This week')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
|
||||
下一周
|
||||
{t('下一周', 'Next week')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<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>
|
||||
<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>
|
||||
{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">
|
||||
{day.items.map((item) => (
|
||||
@ -1386,12 +1410,12 @@ function EventCard({
|
||||
onClick={() => onOpen(item)}
|
||||
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">
|
||||
{formatTime(item.start?.dateTime)} - {formatTime(item.end?.dateTime)}
|
||||
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{item.location?.displayName || '未设置地点'}
|
||||
{item.location?.displayName || t('未设置地点', 'No location set')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@ -14,7 +14,6 @@ import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getStatus,
|
||||
listCommands,
|
||||
listSessions,
|
||||
sendMessage,
|
||||
@ -22,29 +21,10 @@ import {
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ChatMessage, FileAttachment, ProcessWsEvent, 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);
|
||||
}
|
||||
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||||
|
||||
function messageFingerprint(msg: ChatMessage): string {
|
||||
const attachmentKey = (msg.attachments ?? [])
|
||||
@ -76,16 +56,12 @@ function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessage
|
||||
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 {
|
||||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const {
|
||||
sessionId,
|
||||
messages,
|
||||
@ -100,13 +76,8 @@ export default function ChatPage() {
|
||||
setMessages,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setSessions,
|
||||
clearMessages,
|
||||
setWsStatus,
|
||||
setIsThinking,
|
||||
setNanobotReady,
|
||||
resetProcessState,
|
||||
ingestProcessEvent,
|
||||
setSelectedRunId,
|
||||
} = useChatStore();
|
||||
|
||||
@ -124,9 +95,8 @@ export default function ChatPage() {
|
||||
const commandsLoadedRef = useRef(false);
|
||||
const refreshSessionOnReconnectRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
|
||||
const statusCheckInFlightRef = useRef(false);
|
||||
const shouldSnapToLatestRef = useRef(true);
|
||||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!input.startsWith('/') || input.includes(' ')) return [];
|
||||
@ -136,6 +106,28 @@ export default function ChatPage() {
|
||||
);
|
||||
}, [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(
|
||||
() => buildOfficeTaskList({
|
||||
sessionId,
|
||||
@ -143,8 +135,8 @@ export default function ChatPage() {
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
}),
|
||||
[processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
}, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
);
|
||||
|
||||
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
|
||||
@ -152,11 +144,11 @@ export default function ChatPage() {
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listSessions();
|
||||
setSessions(list);
|
||||
useChatStore.getState().setSessions(list);
|
||||
} catch {
|
||||
// backend may be offline during first render
|
||||
}
|
||||
}, [setSessions]);
|
||||
}, []);
|
||||
|
||||
const loadSessionMessages = useCallback(async (key: string) => {
|
||||
const reqSeq = ++loadSessionReqSeq.current;
|
||||
@ -170,6 +162,7 @@ export default function ChatPage() {
|
||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
||||
: detail.messages;
|
||||
setMessages(nextMessages);
|
||||
shouldSnapToLatestRef.current = true;
|
||||
const last = nextMessages[nextMessages.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
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(() => {
|
||||
if (input.startsWith('/') && !input.includes(' ')) {
|
||||
void loadCommands();
|
||||
@ -220,40 +196,29 @@ export default function ChatPage() {
|
||||
setPickerIndex(0);
|
||||
}, [filteredCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages();
|
||||
setIsLoading(false);
|
||||
setIsThinking(false);
|
||||
resetProcessState();
|
||||
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
|
||||
wsManager.connect(wsSessionId);
|
||||
loadSessionMessages(sessionId);
|
||||
}, [clearMessages, loadSessionMessages, resetProcessState, sessionId, setIsLoading, setIsThinking]);
|
||||
void loadSessionMessages(sessionId);
|
||||
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubStatus = wsManager.onStatusChange(async (status) => {
|
||||
setWsStatus(status);
|
||||
if (status === 'connected') {
|
||||
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
|
||||
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);
|
||||
if (wsStatus === 'connected') {
|
||||
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
|
||||
refreshSessionOnReconnectRef.current = false;
|
||||
void loadSessionMessages(useChatStore.getState().sessionId);
|
||||
}
|
||||
});
|
||||
hasConnectedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (wsStatus === 'disconnected' && hasConnectedRef.current) {
|
||||
refreshSessionOnReconnectRef.current = true;
|
||||
}
|
||||
}, [loadSessionMessages, wsStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubMessage = wsManager.onMessage((data) => {
|
||||
if (isSessionUpdatedEvent(data)) {
|
||||
void loadSessions();
|
||||
@ -263,11 +228,6 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessEvent(data)) {
|
||||
ingestProcessEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'status' && data.status === 'thinking') {
|
||||
setIsThinking(true);
|
||||
} else if (data.type === 'message' && data.role === 'assistant') {
|
||||
@ -284,12 +244,9 @@ export default function ChatPage() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
statusCheckCleanupRef.current?.();
|
||||
statusCheckCleanupRef.current = null;
|
||||
unsubStatus();
|
||||
unsubMessage();
|
||||
};
|
||||
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
|
||||
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isThinking) {
|
||||
@ -304,21 +261,34 @@ export default function ChatPage() {
|
||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||
const viewport = messageViewportRef.current;
|
||||
if (!viewport) return;
|
||||
messagesEndRef.current?.scrollIntoView({ block: 'end', 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(() => {
|
||||
shouldSnapToLatestRef.current = true;
|
||||
}, [sessionId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
|
||||
if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
|
||||
scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
|
||||
shouldSnapToLatestRef.current = false;
|
||||
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
|
||||
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandPicker || !pickerRef.current) return;
|
||||
@ -348,7 +318,7 @@ export default function ChatPage() {
|
||||
setPendingFiles([]);
|
||||
setShowCommandPicker(false);
|
||||
|
||||
const msgContent = text || '(仅附件)';
|
||||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||||
addMessage({
|
||||
role: 'user',
|
||||
content: msgContent,
|
||||
@ -392,12 +362,12 @@ export default function ChatPage() {
|
||||
}
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: '发送失败,请检查后端服务是否正在运行。',
|
||||
content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'),
|
||||
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) => {
|
||||
if (showCommandPicker && filteredCommands.length > 0) {
|
||||
@ -436,7 +406,7 @@ export default function ChatPage() {
|
||||
|
||||
for (const file of files) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -447,16 +417,17 @@ export default function ChatPage() {
|
||||
});
|
||||
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
|
||||
} 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 id = `web:${Date.now()}`;
|
||||
setSessionId(id);
|
||||
setSelectedRunId(null);
|
||||
clearMessages();
|
||||
resetProcessState();
|
||||
useChatStore.getState().resetProcessState();
|
||||
try {
|
||||
await createSession(id);
|
||||
} catch {
|
||||
@ -472,7 +443,7 @@ export default function ChatPage() {
|
||||
if (key === sessionId) {
|
||||
setSessionId('web:default');
|
||||
clearMessages();
|
||||
resetProcessState();
|
||||
useChatStore.getState().resetProcessState();
|
||||
}
|
||||
loadSessions();
|
||||
} catch {
|
||||
@ -481,20 +452,21 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
const handleSelectSession = (key: string) => {
|
||||
setSelectedRunId(null);
|
||||
setSessionId(key);
|
||||
};
|
||||
|
||||
const handleCancelRun = async (runId: string) => {
|
||||
const handleCancelRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
await cancelDelegation(runId);
|
||||
} catch (err: any) {
|
||||
addMessage({
|
||||
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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [addMessage, locale]);
|
||||
|
||||
const removePendingFile = useCallback((file: File) => {
|
||||
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
|
||||
@ -503,10 +475,10 @@ export default function ChatPage() {
|
||||
const formatSessionName = (key: string) => {
|
||||
if (key.startsWith('web:')) {
|
||||
const id = key.slice(4);
|
||||
if (id === 'default') return '默认';
|
||||
if (id === 'default') return pickAppText(locale, '默认', 'Default');
|
||||
const numeric = Number(id);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
return new Date(numeric).toLocaleDateString('zh-CN', {
|
||||
return new Date(numeric).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@ -524,14 +496,14 @@ export default function ChatPage() {
|
||||
<div className="p-3">
|
||||
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
|
||||
<Plus className="w-4 h-4" />
|
||||
新对话
|
||||
{pickAppText(locale, '新对话', 'New chat')}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{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) => (
|
||||
<div
|
||||
@ -567,22 +539,22 @@ export default function ChatPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Building2 className="h-4 w-4" />
|
||||
当前任务现场
|
||||
{pickAppText(locale, '当前任务现场', 'Current task floor')}
|
||||
</div>
|
||||
<OfficeStatusBadge status={currentOfficeTask.status} />
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{currentOfficeTask.title}
|
||||
<span className="ml-2">主 Agent: {currentOfficeTask.rootActorName}</span>
|
||||
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/office">查看全部 Office</Link>
|
||||
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
|
||||
查看任务现场
|
||||
{pickAppText(locale, '查看任务现场', 'Open task floor')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@ -597,11 +569,11 @@ export default function ChatPage() {
|
||||
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messageViewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
|
||||
processRuns={sessionProcessRuns}
|
||||
processEvents={sessionProcessEvents}
|
||||
processArtifacts={sessionProcessArtifacts}
|
||||
selectedRunId={selectedSessionRunId}
|
||||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||||
onCancelRun={handleCancelRun}
|
||||
/>
|
||||
</div>
|
||||
@ -623,7 +595,7 @@ export default function ChatPage() {
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
|
||||
</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">
|
||||
<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>
|
||||
{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'}`}>
|
||||
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
|
||||
{command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@ -675,7 +647,7 @@ export default function ChatPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
title="添加附件"
|
||||
title={pickAppText(locale, '添加附件', 'Add attachment')}
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
@ -685,7 +657,7 @@ export default function ChatPage() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
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}
|
||||
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' }}
|
||||
|
||||
@ -19,8 +19,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { PluginInfo } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -32,7 +35,7 @@ export default function PluginsPage() {
|
||||
const data = await listPlugins();
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载插件失败');
|
||||
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -57,15 +60,16 @@ export default function PluginsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Blocks className="w-6 h-6" />
|
||||
插件
|
||||
{pickAppText(locale, '插件', 'Plugins')}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<Button onClick={load} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -86,10 +90,11 @@ export default function PluginsPage() {
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<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">
|
||||
把插件目录放到全局插件目录或当前 workspace 的 <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>,
|
||||
然后重启 Boardware Agent Sandbox。
|
||||
{pickAppText(locale, '把插件目录放到全局插件目录或当前 workspace 的 ', 'Put a plugin directory in the global plugin directory or this workspace\'s ')}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -106,6 +111,7 @@ export default function PluginsPage() {
|
||||
}
|
||||
|
||||
function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
const { locale } = useAppI18n();
|
||||
const [agentsOpen, setAgentsOpen] = useState(true);
|
||||
const [commandsOpen, setCommandsOpen] = useState(true);
|
||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||
@ -132,19 +138,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.agents.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Bot className="w-3 h-3" />
|
||||
{plugin.agents.length} 个智能体
|
||||
{pickAppText(locale, `${plugin.agents.length} 个智能体`, `${plugin.agents.length} agents`)}
|
||||
</span>
|
||||
)}
|
||||
{plugin.commands.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Terminal className="w-3 h-3" />
|
||||
{plugin.commands.length} 条命令
|
||||
{pickAppText(locale, `${plugin.commands.length} 条命令`, `${plugin.commands.length} commands`)}
|
||||
</span>
|
||||
)}
|
||||
{plugin.skills.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{plugin.skills.length} 个技能
|
||||
{pickAppText(locale, `${plugin.skills.length} 个技能`, `${plugin.skills.length} skills`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -157,7 +163,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.agents.length > 0 && (
|
||||
<Section
|
||||
icon={<Bot className="w-3.5 h-3.5" />}
|
||||
label="智能体"
|
||||
label={pickAppText(locale, '智能体', 'Agents')}
|
||||
count={plugin.agents.length}
|
||||
open={agentsOpen}
|
||||
onToggle={() => setAgentsOpen((v) => !v)}
|
||||
@ -186,7 +192,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.commands.length > 0 && (
|
||||
<Section
|
||||
icon={<Terminal className="w-3.5 h-3.5" />}
|
||||
label="命令"
|
||||
label={pickAppText(locale, '命令', 'Commands')}
|
||||
count={plugin.commands.length}
|
||||
open={commandsOpen}
|
||||
onToggle={() => setCommandsOpen((v) => !v)}
|
||||
@ -213,7 +219,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.skills.length > 0 && (
|
||||
<Section
|
||||
icon={<Wrench className="w-3.5 h-3.5" />}
|
||||
label="技能"
|
||||
label={pickAppText(locale, '技能', 'Skills')}
|
||||
count={plugin.skills.length}
|
||||
open={skillsOpen}
|
||||
onToggle={() => setSkillsOpen((v) => !v)}
|
||||
@ -234,18 +240,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: 'global' | 'workspace' }) {
|
||||
const { locale } = useAppI18n();
|
||||
if (source === 'workspace') {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs gap-1">
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
工作区
|
||||
{pickAppText(locale, '工作区', 'Workspace')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
全局
|
||||
{pickAppText(locale, '全局', 'Global')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,8 +24,11 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { Skill } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -39,7 +42,7 @@ export default function SkillsPage() {
|
||||
const data = await listSkills();
|
||||
setSkills(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载技能失败');
|
||||
setError(err.message || pickAppText(locale, '加载技能失败', 'Failed to load skills'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -59,7 +62,7 @@ export default function SkillsPage() {
|
||||
setDeleting(null);
|
||||
loadSkills();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除技能失败');
|
||||
setError(err.message || pickAppText(locale, '删除技能失败', 'Failed to delete the skill'));
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
@ -82,16 +85,16 @@ export default function SkillsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Puzzle className="w-6 h-6" />
|
||||
技能
|
||||
{pickAppText(locale, '技能', 'Skills')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadSkills} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
上传技能
|
||||
{pickAppText(locale, '上传技能', 'Upload skill')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,7 +125,7 @@ export default function SkillsPage() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">
|
||||
确定删除技能 <strong>{deleting}</strong> 吗?此操作不可撤销。
|
||||
{pickAppText(locale, '确定删除技能', 'Delete skill')} <strong>{deleting}</strong> {pickAppText(locale, '吗?此操作不可撤销。', '? This action cannot be undone.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@ -130,14 +133,14 @@ export default function SkillsPage() {
|
||||
size="sm"
|
||||
onClick={() => setDeleting(null)}
|
||||
>
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(deleting)}
|
||||
>
|
||||
删除
|
||||
{pickAppText(locale, '删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -151,18 +154,18 @@ export default function SkillsPage() {
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无技能</p>
|
||||
<p className="text-sm mt-1">上传一个技能 zip 包即可开始使用。</p>
|
||||
<p className="font-medium">{pickAppText(locale, '暂无技能', 'No skills yet')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '上传一个技能 zip 包即可开始使用。', 'Upload a skill zip package to get started.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '描述', 'Description')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -177,22 +180,22 @@ export default function SkillsPage() {
|
||||
<TableCell>
|
||||
{skill.source === 'builtin' ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
{pickAppText(locale, '内置', 'Built in')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">
|
||||
工作区
|
||||
{pickAppText(locale, '工作区', 'Workspace')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skill.available ? (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
可用
|
||||
{pickAppText(locale, '可用', 'Available')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
不可用
|
||||
{pickAppText(locale, '不可用', 'Unavailable')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@ -202,7 +205,7 @@ export default function SkillsPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="下载"
|
||||
title={pickAppText(locale, '下载', 'Download')}
|
||||
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
@ -213,7 +216,7 @@ export default function SkillsPage() {
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(skill.name)}
|
||||
title="删除"
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@ -240,6 +243,7 @@ function UploadSkillForm({
|
||||
onCancel: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -253,7 +257,7 @@ function UploadSkillForm({
|
||||
await uploadSkill(file);
|
||||
onDone();
|
||||
} catch (err: any) {
|
||||
onError(err.message || '上传失败');
|
||||
onError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@ -263,7 +267,7 @@ function UploadSkillForm({
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<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}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@ -273,7 +277,7 @@ function UploadSkillForm({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="skill-zip">
|
||||
技能压缩包
|
||||
{pickAppText(locale, '技能压缩包', 'Skill archive')}
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
压缩包中必须包含 `SKILL.md` 文件
|
||||
{pickAppText(locale, '压缩包中必须包含 `SKILL.md` 文件', 'The archive must contain a `SKILL.md` file')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={uploading}>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
上传中...
|
||||
{pickAppText(locale, '上传中...', 'Uploading...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
上传
|
||||
{pickAppText(locale, '上传', 'Upload')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@ -27,8 +27,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { SystemStatus } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function StatusPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -43,7 +46,7 @@ export default function StatusPage() {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '连接后端失败');
|
||||
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -79,7 +82,7 @@ export default function StatusPage() {
|
||||
setRestartDialogOpen(false);
|
||||
setRestarting(true);
|
||||
} 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">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<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">
|
||||
请确认后端服务已启动,并且当前页面可以访问它。
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重试
|
||||
{pickAppText(locale, '重试', 'Retry')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -121,10 +122,10 @@ export default function StatusPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<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}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -133,17 +134,17 @@ export default function StatusPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Server className="w-4 h-4" />
|
||||
系统信息
|
||||
{pickAppText(locale, '系统信息', 'System information')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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">
|
||||
{restarting
|
||||
? '正在重启当前 docker,服务恢复后页面会自动刷新。'
|
||||
: '会重启当前 docker 容器。重启完成后需要重新登录。'}
|
||||
? pickAppText(locale, '正在重启当前 docker,服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.')
|
||||
: pickAppText(locale, '会重启当前 docker 容器。重启完成后需要重新登录。', 'This restarts the current Docker container. You will need to sign in again afterwards.')}
|
||||
</p>
|
||||
{restartError ? (
|
||||
<p className="text-sm text-destructive">{restartError}</p>
|
||||
@ -164,15 +165,15 @@ export default function StatusPage() {
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认重启当前实例?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
|
||||
<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>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={restarting}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestart} disabled={restarting}>
|
||||
{restarting ? '重启中...' : '确认 Restart'}
|
||||
{restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@ -186,15 +187,15 @@ export default function StatusPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cpu className="w-4 h-4" />
|
||||
智能体配置
|
||||
{pickAppText(locale, '智能体配置', 'Agent configuration')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow label="模型" value={status.model} />
|
||||
<InfoRow label="最大令牌数" value={String(status.max_tokens)} />
|
||||
<InfoRow label="温度" value={String(status.temperature)} />
|
||||
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
|
||||
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
|
||||
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
|
||||
<InfoRow
|
||||
label="最大工具迭代次数"
|
||||
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
value={String(status.max_tool_iterations)}
|
||||
/>
|
||||
</CardContent>
|
||||
@ -205,7 +206,7 @@ export default function StatusPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Key className="w-4 h-4" />
|
||||
提供商
|
||||
{pickAppText(locale, '提供商', 'Providers')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -239,7 +240,7 @@ export default function StatusPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Radio className="w-4 h-4" />
|
||||
通道
|
||||
{pickAppText(locale, '通道', 'Channels')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -250,7 +251,7 @@ export default function StatusPage() {
|
||||
variant={ch.enabled ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{ch.enabled ? '开启' : '关闭'}
|
||||
{ch.enabled ? pickAppText(locale, '开启', 'On') : pickAppText(locale, '关闭', 'Off')}
|
||||
</Badge>
|
||||
<span className="capitalize">{ch.name}</span>
|
||||
</div>
|
||||
@ -264,16 +265,16 @@ export default function StatusPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
调度器
|
||||
{pickAppText(locale, '调度器', 'Scheduler')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow
|
||||
label="状态"
|
||||
value={status.cron.enabled ? '运行中' : '已停止'}
|
||||
label={pickAppText(locale, '状态', 'Status')}
|
||||
value={status.cron.enabled ? pickAppText(locale, '运行中', 'Running') : pickAppText(locale, '已停止', 'Stopped')}
|
||||
ok={status.cron.enabled}
|
||||
/>
|
||||
<InfoRow label="任务数" value={String(status.cron.jobs)} />
|
||||
<InfoRow label={pickAppText(locale, '任务数', 'Jobs')} value={String(status.cron.jobs)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -4,8 +4,11 @@ import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function LoginRedirectPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
@ -15,7 +18,9 @@ export default function LoginRedirectPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,8 +4,11 @@ import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function RegisterRedirectPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
@ -15,7 +18,9 @@ export default function RegisterRedirectPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
const HANDOFF_STATE_KEY = 'nanobot_handoff_state';
|
||||
@ -78,6 +80,7 @@ function clearHandoffState(): void {
|
||||
}
|
||||
|
||||
export default function HandoffPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
const [error, setError] = useState('');
|
||||
@ -91,7 +94,7 @@ export default function HandoffPage() {
|
||||
|
||||
if (!handoff.code && !handoff.accessToken) {
|
||||
clearHandoffState();
|
||||
setError('缺少登录凭证,无法进入目标前端。');
|
||||
setError(pickAppText(locale, '缺少登录凭证,无法进入目标前端。', 'Missing login credentials. Unable to enter the target frontend.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -116,7 +119,7 @@ export default function HandoffPage() {
|
||||
clearHandoffState();
|
||||
clearTokens();
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold">正在进入目标前端...</h1>
|
||||
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : <p className="mt-3 text-sm text-muted-foreground">正在同步登录态,请稍候。</p>}
|
||||
<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">
|
||||
{pickAppText(locale, '正在同步登录态,请稍候。', 'Syncing your sign-in state. Please wait.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { AppI18nProvider } from '@/lib/i18n/provider';
|
||||
import { getServerAppLocale } from '@/lib/i18n/server';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Boardware Agent Sandbox',
|
||||
description: '个人 AI 助手',
|
||||
description: 'Boardware Agent Sandbox',
|
||||
icons: {
|
||||
icon: '/boardware-logo.jpg',
|
||||
},
|
||||
@ -14,9 +16,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = getServerAppLocale();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<body className="bg-background text-foreground">{children}</body>
|
||||
<html lang={locale} className="dark">
|
||||
<body className="bg-background text-foreground">
|
||||
<AppI18nProvider initialLocale={locale}>{children}</AppI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
122
app-instance/frontend/components/AppRuntimeBridge.tsx
Normal file
122
app-instance/frontend/components/AppRuntimeBridge.tsx
Normal 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;
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { useEffect } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
export default function AuthGuard({
|
||||
@ -13,6 +15,7 @@ export default function AuthGuard({
|
||||
children: React.ReactNode;
|
||||
minHeightClassName?: string;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@ -86,7 +89,7 @@ export default function AuthGuard({
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<div className={`flex ${minHeightClassName} items-center justify-center`}>
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,37 +4,54 @@ import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
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 { 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';
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
key:
|
||||
| 'chat'
|
||||
| 'status'
|
||||
| 'office'
|
||||
| 'skills'
|
||||
| 'plugins'
|
||||
| 'agents'
|
||||
| 'mcp'
|
||||
| 'outlook'
|
||||
| 'marketplace'
|
||||
| 'files';
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ name: '对话', href: '/', icon: MessageSquare },
|
||||
{ name: '状态', href: '/status', icon: Activity },
|
||||
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||
{ name: '技能', href: '/skills', icon: Puzzle },
|
||||
{ name: '插件', href: '/plugins', icon: Blocks },
|
||||
{ name: '智能体', href: '/agents', icon: Bot },
|
||||
{ name: 'MCP', href: '/mcp', icon: ServerCog },
|
||||
{ name: 'Outlook', href: '/outlook', icon: Mail },
|
||||
{ name: '市场', href: '/marketplace', icon: Store },
|
||||
{ name: '文件', href: '/files', icon: FolderOpen },
|
||||
{ name: '帮助', href: '/help', icon: HelpCircle },
|
||||
{ key: 'chat', href: '/', icon: MessageSquare },
|
||||
{ key: 'status', href: '/status', icon: Activity },
|
||||
{ key: 'office', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||
{ key: 'skills', href: '/skills', icon: Puzzle },
|
||||
{ key: 'plugins', href: '/plugins', icon: Blocks },
|
||||
{ key: 'agents', href: '/agents', icon: Bot },
|
||||
{ key: 'mcp', href: '/mcp', icon: ServerCog },
|
||||
{ key: 'outlook', href: '/outlook', icon: Mail },
|
||||
{ key: 'marketplace', href: '/marketplace', icon: Store },
|
||||
{ key: 'files', href: '/files', icon: FolderOpen },
|
||||
];
|
||||
|
||||
const AUTH_ITEMS = [
|
||||
{ name: '登录', href: '/login', icon: LogIn },
|
||||
{ name: '注册', href: '/register', icon: UserPlus },
|
||||
];
|
||||
{ key: 'login', href: '/login', icon: LogIn },
|
||||
{ key: 'register', href: '/register', icon: UserPlus },
|
||||
] as const;
|
||||
|
||||
function ConnectionDot() {
|
||||
const { locale } = useAppI18n();
|
||||
const wsStatus = useChatStore((s) => s.wsStatus);
|
||||
const nanobotReady = useChatStore((s) => s.nanobotReady);
|
||||
|
||||
@ -49,15 +66,7 @@ function ConnectionDot() {
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500';
|
||||
|
||||
const label = isOnline
|
||||
? '已连接'
|
||||
: isChecking
|
||||
? '检查中'
|
||||
: wsStatus === 'connecting'
|
||||
? '连接中'
|
||||
: isOffline && wsStatus === 'connected'
|
||||
? '服务离线'
|
||||
: '未连接';
|
||||
const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
@ -68,12 +77,32 @@ function ConnectionDot() {
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const user = useChatStore((s) => s.user);
|
||||
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
|
||||
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 () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
@ -81,6 +110,8 @@ const Header = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -117,28 +148,68 @@ const Header = () => {
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.name}
|
||||
{navLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-foreground">
|
||||
<UserCircle2 className="w-4 h-4" />
|
||||
<span className="max-w-32 truncate">{user.username}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Avatar className="h-8 w-8 border border-border/60">
|
||||
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<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 ? (
|
||||
AUTH_ITEMS.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
@ -154,7 +225,7 @@ const Header = () => {
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.name}
|
||||
{authLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
|
||||
41
app-instance/frontend/components/LanguageSwitcher.tsx
Normal file
41
app-instance/frontend/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,9 @@ import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { appArtifactPreview, appFeedRoleLabel, appStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type RunCardPhase = 'live' | 'exiting' | 'collapsed';
|
||||
@ -51,15 +54,6 @@ function accentFor(index: number) {
|
||||
return AGENT_ACCENTS[index % AGENT_ACCENTS.length];
|
||||
}
|
||||
|
||||
function statusLabel(status: ProcessRun['status']) {
|
||||
if (status === 'done') return '已完成';
|
||||
if (status === 'error') return '失败';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
if (status === 'waiting') return '等待中';
|
||||
if (status === 'queued') return '排队中';
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRun['status']) {
|
||||
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
|
||||
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
|
||||
@ -69,13 +63,6 @@ function statusTone(status: ProcessRun['status']) {
|
||||
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
|
||||
}
|
||||
|
||||
function roleLabel(role: AgentFeedItem['role']) {
|
||||
if (role === 'user') return '主 agent';
|
||||
if (role === 'tool') return '工具输出';
|
||||
if (role === 'system') return '状态';
|
||||
return '子 agent';
|
||||
}
|
||||
|
||||
function feedTone(role: AgentFeedItem['role']) {
|
||||
if (role === 'user') {
|
||||
return 'ml-6 border-border/70 bg-muted/60 text-foreground';
|
||||
@ -89,22 +76,6 @@ function feedTone(role: AgentFeedItem['role']) {
|
||||
return 'mr-6 border-border/70 bg-background/80 text-foreground';
|
||||
}
|
||||
|
||||
function artifactPreview(artifact: ProcessArtifact): string {
|
||||
if (artifact.artifact_type === 'link' && artifact.url) {
|
||||
return `${artifact.title}\n${artifact.url}`;
|
||||
}
|
||||
if ((artifact.artifact_type === 'text' || artifact.artifact_type === 'markdown') && artifact.content) {
|
||||
return `${artifact.title}\n${artifact.content}`;
|
||||
}
|
||||
if (artifact.artifact_type === 'json') {
|
||||
return `${artifact.title}\n已生成结构化结果`;
|
||||
}
|
||||
if (artifact.file_id) {
|
||||
return `${artifact.title}\n已生成文件输出`;
|
||||
}
|
||||
return artifact.title;
|
||||
}
|
||||
|
||||
function delegatedTask(run: ProcessRun): string | null {
|
||||
const value = run.metadata?.delegated_task;
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@ -114,6 +85,7 @@ function buildFeed(
|
||||
run: ProcessRun,
|
||||
events: ProcessEvent[],
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: 'zh-CN' | 'en-US',
|
||||
): AgentFeedItem[] {
|
||||
const items: AgentFeedItem[] = [];
|
||||
let hasLeadBubble = false;
|
||||
@ -160,7 +132,7 @@ function buildFeed(
|
||||
key: artifact.artifact_id,
|
||||
created_at: artifact.created_at,
|
||||
role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant',
|
||||
text: artifactPreview(artifact),
|
||||
text: appArtifactPreview(artifact, locale),
|
||||
});
|
||||
}
|
||||
|
||||
@ -181,12 +153,12 @@ function buildFeed(
|
||||
.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()) {
|
||||
return run.summary.trim();
|
||||
}
|
||||
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[]) {
|
||||
@ -256,7 +228,13 @@ function useRunCardPhases(runs: ProcessRun[]) {
|
||||
return phases;
|
||||
}
|
||||
|
||||
function AgentBubble({ item }: { item: AgentFeedItem }) {
|
||||
function AgentBubble({
|
||||
item,
|
||||
locale,
|
||||
}: {
|
||||
item: AgentFeedItem;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<span>{roleLabel(item.role)}</span>
|
||||
<span>{appFeedRoleLabel(item.role, locale)}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
</div>
|
||||
@ -281,6 +259,7 @@ function LiveAgentCard({
|
||||
phase,
|
||||
accentIndex,
|
||||
onSelect,
|
||||
locale,
|
||||
}: {
|
||||
run: ProcessRun;
|
||||
feed: AgentFeedItem[];
|
||||
@ -289,6 +268,7 @@ function LiveAgentCard({
|
||||
phase: RunCardPhase;
|
||||
accentIndex: number;
|
||||
onSelect: () => void;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
}) {
|
||||
const showSpinner = !TERMINAL_STATUSES.has(run.status);
|
||||
const accent = accentFor(accentIndex);
|
||||
@ -308,13 +288,13 @@ function LiveAgentCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
|
||||
<span>Sub-Agent</span>
|
||||
<span>{pickAppText(locale, '子 Agent', 'Sub-agent')}</span>
|
||||
</div>
|
||||
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
|
||||
{statusLabel(run.status)}
|
||||
{appStatusLabel(run.status, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@ -322,11 +302,11 @@ function LiveAgentCard({
|
||||
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
|
||||
{feed.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-background/60 px-4 py-5 text-center text-sm text-muted-foreground">
|
||||
等待子 agent 输出...
|
||||
{pickAppText(locale, '等待子 agent 输出...', 'Waiting for sub-agent output...')}
|
||||
</div>
|
||||
)}
|
||||
{feed.map((item) => (
|
||||
<AgentBubble key={item.key} item={item} />
|
||||
<AgentBubble key={item.key} item={item} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -335,10 +315,10 @@ function LiveAgentCard({
|
||||
{showSpinner && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2.5 py-1 text-foreground/80">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
运行中
|
||||
{pickAppText(locale, '运行中', 'Running')}
|
||||
</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>}
|
||||
</div>
|
||||
</button>
|
||||
@ -352,6 +332,7 @@ function ResultCard({
|
||||
selected,
|
||||
accentIndex,
|
||||
onSelect,
|
||||
locale,
|
||||
}: {
|
||||
run: ProcessRun;
|
||||
summary: string;
|
||||
@ -359,6 +340,7 @@ function ResultCard({
|
||||
selected: boolean;
|
||||
accentIndex: number;
|
||||
onSelect: () => void;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
}) {
|
||||
const accent = accentFor(accentIndex);
|
||||
|
||||
@ -374,7 +356,7 @@ function ResultCard({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Result</div>
|
||||
<div className="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>
|
||||
<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-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
|
||||
{statusLabel(run.status)}
|
||||
{appStatusLabel(run.status, locale)}
|
||||
</Badge>
|
||||
{artifactCount > 0 && <span>{artifactCount} 个输出</span>}
|
||||
{artifactCount > 0 && <span>{pickAppText(locale, `${artifactCount} 个输出`, `${artifactCount} outputs`)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@ -407,6 +389,7 @@ export function AgentTeamBlock({
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const phases = useRunCardPhases(memberRuns);
|
||||
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="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Agent Results
|
||||
{pickAppText(locale, '智能体结果', 'Agent results')}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div>
|
||||
</div>
|
||||
{terminalRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts, locale);
|
||||
return (
|
||||
<ResultCard
|
||||
key={run.run_id}
|
||||
run={run}
|
||||
summary={runSummary(run, feed)}
|
||||
summary={runSummary(run, feed, locale)}
|
||||
artifactCount={runArtifacts.length}
|
||||
selected={selectedRunId === run.run_id}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -461,25 +445,27 @@ export function AgentTeamBlock({
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Agent Team
|
||||
{pickAppText(locale, '智能体团队', 'Agent team')}
|
||||
</div>
|
||||
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{liveCount > 0 ? `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent` : '子 agent 已完成,结果已折叠为摘要卡片'}
|
||||
{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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCancelRoot && (
|
||||
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<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 variant="outline" className={cn('border', statusTone(rootRun.status))}>
|
||||
{statusLabel(rootRun.status)}
|
||||
{appStatusLabel(rootRun.status, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -490,7 +476,7 @@ export function AgentTeamBlock({
|
||||
{liveRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts, locale);
|
||||
return (
|
||||
<LiveAgentCard
|
||||
key={run.run_id}
|
||||
@ -501,6 +487,7 @@ export function AgentTeamBlock({
|
||||
phase={phases[run.run_id] || 'live'}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -513,16 +500,17 @@ export function AgentTeamBlock({
|
||||
{collapsedRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts, locale);
|
||||
return (
|
||||
<ResultCard
|
||||
key={run.run_id}
|
||||
run={run}
|
||||
summary={runSummary(run, feed)}
|
||||
summary={runSummary(run, feed, locale)}
|
||||
artifactCount={runArtifacts.length}
|
||||
selected={selectedRunId === run.run_id}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -7,33 +7,9 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
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;
|
||||
}
|
||||
import { appActorTypeLabel, appEventKindLabel, appStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
function artifactIcon(type: ProcessArtifact['artifact_type']) {
|
||||
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" />;
|
||||
}
|
||||
|
||||
function renderArtifactBody(artifact: ProcessArtifact) {
|
||||
function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US') {
|
||||
if (artifact.artifact_type === 'json' && artifact.data !== undefined) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="text-xs text-foreground/90 whitespace-pre-wrap break-words">
|
||||
{artifact.content || '(空产物)'}
|
||||
{artifact.content || pickAppText(locale, '(空产物)', '(Empty artifact)')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -73,6 +49,7 @@ export function ArtifactSidebar({
|
||||
events: ProcessEvent[];
|
||||
artifacts: ProcessArtifact[];
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const runArtifacts = selectedRun
|
||||
? artifacts.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: artifacts;
|
||||
@ -90,9 +67,11 @@ export function ArtifactSidebar({
|
||||
return (
|
||||
<div className="h-full bg-card/60 flex flex-col border-l 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">
|
||||
{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>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 px-4 py-4">
|
||||
@ -101,24 +80,24 @@ export function ArtifactSidebar({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FolderSearch className="w-4 h-4" />
|
||||
任务摘要
|
||||
{pickAppText(locale, '任务摘要', 'Task summary')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2 text-sm">
|
||||
{selectedRun ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline">{actorTypeLabel(selectedRun.actor_type)}</Badge>
|
||||
<Badge variant="outline">{statusLabel(selectedRun.status)}</Badge>
|
||||
<Badge variant="outline">{appActorTypeLabel(selectedRun.actor_type, locale)}</Badge>
|
||||
<Badge variant="outline">{appStatusLabel(selectedRun.status, locale)}</Badge>
|
||||
{selectedRun.source && <Badge variant="secondary">{selectedRun.source}</Badge>}
|
||||
</div>
|
||||
<div className="font-medium">{selectedRun.title}</div>
|
||||
<div className="text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{selectedRun.summary || '暂时还没有最终摘要。'}
|
||||
{selectedRun.summary || pickAppText(locale, '暂时还没有最终摘要。', 'No final summary yet.')}
|
||||
</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>
|
||||
</Card>
|
||||
@ -127,22 +106,22 @@ export function ArtifactSidebar({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<MessagesSquare className="w-4 h-4" />
|
||||
事件记录
|
||||
{pickAppText(locale, '事件记录', 'Events')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
{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) => (
|
||||
<div key={event.event_id}>
|
||||
<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">
|
||||
<span>{eventKindLabel(event.kind)}</span>
|
||||
{event.status && <span>{statusLabel(event.status)}</span>}
|
||||
<span>{appEventKindLabel(event.kind, locale)}</span>
|
||||
{event.status && <span>{appStatusLabel(event.status, locale)}</span>}
|
||||
</div>
|
||||
<div className="text-xs whitespace-pre-wrap break-words">
|
||||
{event.text || '结构化更新'}
|
||||
{event.text || pickAppText(locale, '结构化更新', 'Structured update')}
|
||||
</div>
|
||||
</div>
|
||||
{index < runEvents.length - 1 && <Separator className="my-2" />}
|
||||
@ -155,12 +134,12 @@ export function ArtifactSidebar({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileOutput className="w-4 h-4" />
|
||||
产物列表
|
||||
{pickAppText(locale, '产物列表', 'Artifacts')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{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) => (
|
||||
<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>
|
||||
{renderArtifactBody(artifact)}
|
||||
{renderArtifactBody(artifact, locale)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@ -6,6 +6,8 @@ import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/t
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export function ChatWorkbench({
|
||||
messages,
|
||||
@ -30,6 +32,29 @@ export function ChatWorkbench({
|
||||
onSelectRun: (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
|
||||
? processRuns.find((item) => item.run_id === selectedRunId) || null
|
||||
: null;
|
||||
@ -48,25 +73,29 @@ export function ChatWorkbench({
|
||||
)
|
||||
);
|
||||
const desktopColumns = hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||
? 'grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'grid-cols-[minmax(0,1fr)]';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`hidden lg:grid h-full ${desktopColumns}`}>
|
||||
const 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}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className={`grid h-full ${desktopColumns}`}>
|
||||
<div className="min-h-0">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
{messageList}
|
||||
</div>
|
||||
{hasResultsPanel && (
|
||||
<div className="min-h-0">
|
||||
@ -78,55 +107,33 @@ export function ChatWorkbench({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="lg:hidden h-full">
|
||||
{!hasResultsPanel ? (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-3 border-b border-border">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">聊天</TabsTrigger>
|
||||
{hasResultsPanel && <TabsTrigger value="results">结果</TabsTrigger>}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="chat" className="flex-1 min-h-0 mt-0">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
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>
|
||||
</>
|
||||
return (
|
||||
<div className="h-full">
|
||||
{!hasResultsPanel ? (
|
||||
messageList
|
||||
) : (
|
||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-3 border-b border-border">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger>
|
||||
<TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="chat" className="flex-1 min-h-0 mt-0">
|
||||
{messageList}
|
||||
</TabsContent>
|
||||
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
||||
<ArtifactSidebar
|
||||
selectedRun={selectedRun}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||
@ -115,6 +117,20 @@ type AgentTeamGroup = {
|
||||
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 {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
@ -194,9 +210,20 @@ export function MessageList({
|
||||
onSelectRun: (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 messageItems = messages.map((message, index) => ({
|
||||
const messageItems = visibleMessages.map((message, index) => ({
|
||||
kind: 'message' as const,
|
||||
key: `${message.role}:${message.timestamp || index}:${index}`,
|
||||
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
||||
@ -204,12 +231,12 @@ export function MessageList({
|
||||
message,
|
||||
}));
|
||||
const teamItems = teamGroups.map((group, index) => ({
|
||||
kind: 'team' as const,
|
||||
key: `team:${group.rootRun.run_id}`,
|
||||
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index,
|
||||
order: messages.length + index,
|
||||
group,
|
||||
}));
|
||||
kind: 'team' as const,
|
||||
key: `team:${group.rootRun.run_id}`,
|
||||
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + visibleMessages.length + index,
|
||||
order: visibleMessages.length + index,
|
||||
group,
|
||||
}));
|
||||
|
||||
return [...messageItems, ...teamItems].sort((a, b) => {
|
||||
if (a.sortTime !== b.sortTime) {
|
||||
@ -217,16 +244,16 @@ export function MessageList({
|
||||
}
|
||||
return a.order - b.order;
|
||||
});
|
||||
}, [messages, teamGroups]);
|
||||
}, [teamGroups, visibleMessages]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||
<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">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -251,7 +278,7 @@ export function MessageList({
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||
<Bot className="w-5 h-5" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">思考中...</span>
|
||||
<span className="text-sm">{pickAppText(locale, '思考中...', 'Thinking...')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -7,35 +7,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
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) {
|
||||
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';
|
||||
@ -63,6 +39,7 @@ export function ProcessLane({
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const sortedRuns = [...runs].sort((a, b) => {
|
||||
const at = new Date(a.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="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">执行过程</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">智能体、A2A、MCP 的实时过程</p>
|
||||
<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">{pickAppText(locale, '智能体、A2A、MCP 的实时过程', 'Live process stream for agents, A2A, and MCP')}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sortedRuns.length} 个任务
|
||||
{pickAppText(locale, `${sortedRuns.length} 个任务`, `${sortedRuns.length} tasks`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 px-4 py-4">
|
||||
@ -120,7 +97,7 @@ export function ProcessLane({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className={cn('text-[10px] border', statusTone(run.status))}>
|
||||
{statusLabel(run.status)}
|
||||
{appStatusLabel(run.status, locale)}
|
||||
</Badge>
|
||||
{canCancel && (
|
||||
<Button
|
||||
@ -133,7 +110,7 @@ export function ProcessLane({
|
||||
}}
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-1" />
|
||||
取消
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -141,9 +118,9 @@ export function ProcessLane({
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<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.parent_run_id && <span>子任务</span>}
|
||||
{run.parent_run_id && <span>{pickAppText(locale, '子任务', 'Subtask')}</span>}
|
||||
</div>
|
||||
{run.summary && (
|
||||
<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' && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
等待首个事件...
|
||||
{pickAppText(locale, '等待首个事件...', 'Waiting for the first event...')}
|
||||
</div>
|
||||
)}
|
||||
{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 className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||
<span>{eventKindLabel(event.kind)}</span>
|
||||
{event.status && <span>{statusLabel(event.status)}</span>}
|
||||
<span>{appEventKindLabel(event.kind, locale)}</span>
|
||||
{event.status && <span>{appStatusLabel(event.status, locale)}</span>}
|
||||
</div>
|
||||
<div className="text-foreground/90 whitespace-pre-wrap break-words">
|
||||
{event.text || '结构化更新'}
|
||||
{event.text || pickAppText(locale, '结构化更新', 'Structured update')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{run.status === 'error' && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-300">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
此任务执行失败。
|
||||
{pickAppText(locale, '此任务执行失败。', 'This task failed.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office';
|
||||
@ -12,6 +13,8 @@ export function OfficeStatusBadge({
|
||||
status: OfficeTaskStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@ -27,16 +30,16 @@ export function OfficeStatusBadge({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{officeTaskStatusLabel(status)}
|
||||
{officeTaskStatusLabel(status, locale)}
|
||||
</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 '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@ -44,18 +47,18 @@ export function formatOfficeTime(value?: string | null): string {
|
||||
}).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 < 1000) return '<1s';
|
||||
if (durationMs < 1000) return locale === 'en-US' ? '<1s' : '<1秒';
|
||||
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${remainingSeconds}s`;
|
||||
return `${remainingSeconds}s`;
|
||||
if (hours > 0) return locale === 'en-US' ? `${hours}h ${minutes}m` : `${hours}小时 ${minutes}分`;
|
||||
if (minutes > 0) return locale === 'en-US' ? `${minutes}m ${remainingSeconds}s` : `${minutes}分 ${remainingSeconds}秒`;
|
||||
return locale === 'en-US' ? `${remainingSeconds}s` : `${remainingSeconds}秒`;
|
||||
}
|
||||
|
||||
export function progressPercent(value: number | null, max: number | null): number {
|
||||
|
||||
@ -4,6 +4,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Building2, Clock3 } from 'lucide-react';
|
||||
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TASK_MANAGEMENT_TABS = [
|
||||
@ -14,7 +16,7 @@ const TASK_MANAGEMENT_TABS = [
|
||||
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
|
||||
},
|
||||
{
|
||||
label: '定时任务',
|
||||
label: 'Scheduled tasks',
|
||||
href: '/cron',
|
||||
icon: Clock3,
|
||||
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
|
||||
@ -22,6 +24,7 @@ const TASK_MANAGEMENT_TABS = [
|
||||
] as const;
|
||||
|
||||
export function TaskManagementTabs() {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@ -43,7 +46,9 @@ export function TaskManagementTabs() {
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
{tab.href === '/cron'
|
||||
? pickAppText(locale, '定时任务', 'Scheduled tasks')
|
||||
: pickAppText(locale, '办公室', 'Office')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -31,6 +31,7 @@ import type {
|
||||
UiMcpServerDescriptor,
|
||||
WsEvent,
|
||||
} from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
|
||||
@ -90,9 +91,10 @@ function withTimeout(
|
||||
signal?: AbortSignal,
|
||||
timeoutMs: number = REQUEST_TIMEOUT_MS
|
||||
): { signal: AbortSignal; cleanup: () => void } {
|
||||
const locale = getCurrentAppLocale();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
controller.abort(new DOMException('请求超时', 'AbortError'));
|
||||
controller.abort(new DOMException(pickAppText(locale, '请求超时', 'Request timed out'), 'AbortError'));
|
||||
}, timeoutMs);
|
||||
|
||||
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> {
|
||||
const locale = getCurrentAppLocale();
|
||||
const mergedHeaders = {
|
||||
...authHeaders(),
|
||||
...(options?.headers as Record<string, string> | undefined),
|
||||
@ -170,7 +173,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
throw new Error(pickAppText(locale, '请求超时', 'Request timed out'));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -191,7 +194,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`接口错误 ${res.status}: ${detail}`);
|
||||
throw new Error(`${pickAppText(locale, '接口错误', 'API error')} ${res.status}: ${detail}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@ -262,6 +265,7 @@ export function streamMessage(
|
||||
onError: (error: string) => void
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
const locale = getCurrentAppLocale();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@ -273,7 +277,7 @@ export function streamMessage(
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
onError(`HTTP 错误 ${res.status}`);
|
||||
onError(`${pickAppText(locale, 'HTTP 错误', 'HTTP error')} ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -308,7 +312,7 @@ export function streamMessage(
|
||||
}
|
||||
} catch (err: any) {
|
||||
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',
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<FileAttachment> {
|
||||
const locale = getCurrentAppLocale();
|
||||
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();
|
||||
@ -1086,11 +1091,11 @@ export async function uploadFile(
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data);
|
||||
} 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);
|
||||
});
|
||||
|
||||
@ -1142,8 +1147,9 @@ export async function uploadToWorkspace(
|
||||
dirPath: string = '',
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<WorkspaceItem> {
|
||||
const locale = getCurrentAppLocale();
|
||||
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();
|
||||
@ -1168,11 +1174,11 @@ export async function uploadToWorkspace(
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
||||
78
app-instance/frontend/lib/i18n/common.ts
Normal file
78
app-instance/frontend/lib/i18n/common.ts
Normal 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');
|
||||
}
|
||||
76
app-instance/frontend/lib/i18n/core.ts
Normal file
76
app-instance/frontend/lib/i18n/core.ts
Normal 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;
|
||||
}
|
||||
57
app-instance/frontend/lib/i18n/provider.tsx
Normal file
57
app-instance/frontend/lib/i18n/provider.tsx
Normal 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;
|
||||
}
|
||||
17
app-instance/frontend/lib/i18n/server.ts
Normal file
17
app-instance/frontend/lib/i18n/server.ts
Normal 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';
|
||||
}
|
||||
@ -224,4 +224,40 @@ describe('office view builders', () => {
|
||||
expect(tasks[1].taskId).toBe('run-done');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ProcessRunStatus,
|
||||
Session,
|
||||
} from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']);
|
||||
const STALE_WAITING_MS = 2 * 60 * 1000;
|
||||
@ -200,8 +201,8 @@ function latestTimestamp(values: Array<string | null | undefined>): string | nul
|
||||
return selected;
|
||||
}
|
||||
|
||||
function getSessionLabel(sessions: Session[], sessionId: string | null): string {
|
||||
if (!sessionId) return '未关联会话';
|
||||
function getSessionLabel(sessions: Session[], sessionId: string | null, locale: AppLocale): string {
|
||||
if (!sessionId) return pickAppText(locale, '未关联会话', 'No session linked');
|
||||
const session = sessions.find((item) => item.key === sessionId);
|
||||
if (!session) return sessionId;
|
||||
return session.path?.trim() || session.key;
|
||||
@ -279,6 +280,7 @@ function deriveStageLabel(
|
||||
run: ProcessRun,
|
||||
runEvents: ProcessEvent[],
|
||||
fallbackStatus: OfficeTaskStatus,
|
||||
locale: AppLocale,
|
||||
): string | null {
|
||||
const runMetadataLabel = readMetadataString(run.metadata, [
|
||||
'stage_label',
|
||||
@ -299,13 +301,13 @@ function deriveStageLabel(
|
||||
if (label) return label;
|
||||
}
|
||||
|
||||
if (fallbackStatus === 'running') return '执行中';
|
||||
if (fallbackStatus === 'waiting') return '等待中';
|
||||
if (fallbackStatus === 'queued') return '排队中';
|
||||
if (fallbackStatus === 'done') return '已完成';
|
||||
if (fallbackStatus === 'error') return '失败';
|
||||
if (fallbackStatus === 'cancelled') return '已取消';
|
||||
if (fallbackStatus === 'blocked') return '阻塞';
|
||||
if (fallbackStatus === 'running') return pickAppText(locale, '执行中', 'Running');
|
||||
if (fallbackStatus === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
|
||||
if (fallbackStatus === 'queued') return pickAppText(locale, '排队中', 'Queued');
|
||||
if (fallbackStatus === 'done') return pickAppText(locale, '已完成', 'Done');
|
||||
if (fallbackStatus === 'error') return pickAppText(locale, '失败', 'Error');
|
||||
if (fallbackStatus === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
if (fallbackStatus === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -330,13 +332,13 @@ function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): Offic
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
function zoneLabel(zoneId: OfficeZoneId): string {
|
||||
if (zoneId === 'reception') return '接待区';
|
||||
if (zoneId === 'workspace') return '工位区';
|
||||
if (zoneId === 'collab') return '协作区';
|
||||
if (zoneId === 'research') return '研究区';
|
||||
if (zoneId === 'alert') return '异常区';
|
||||
return '完成区';
|
||||
function zoneLabel(zoneId: OfficeZoneId, locale: AppLocale): string {
|
||||
if (zoneId === 'reception') return pickAppText(locale, '接待区', 'Reception');
|
||||
if (zoneId === 'workspace') return pickAppText(locale, '工位区', 'Workspace');
|
||||
if (zoneId === 'collab') return pickAppText(locale, '协作区', 'Collaboration');
|
||||
if (zoneId === 'research') return pickAppText(locale, '研究区', 'Research');
|
||||
if (zoneId === 'alert') return pickAppText(locale, '异常区', 'Alerts');
|
||||
return pickAppText(locale, '完成区', 'Completed');
|
||||
}
|
||||
|
||||
function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] {
|
||||
@ -378,7 +380,7 @@ function selectDisplayRun(
|
||||
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;
|
||||
const direct = firstString(run.summary);
|
||||
if (direct) return direct;
|
||||
@ -388,13 +390,14 @@ function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | n
|
||||
return event.text!.trim();
|
||||
}
|
||||
}
|
||||
return '任务执行失败';
|
||||
return pickAppText(locale, '任务执行失败', 'Task execution failed');
|
||||
}
|
||||
|
||||
function deriveProgress(
|
||||
rootRun: ProcessRun,
|
||||
taskRuns: ProcessRun[],
|
||||
taskViews: OfficeTaskView[],
|
||||
locale: AppLocale,
|
||||
): OfficeProgressView {
|
||||
const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']);
|
||||
const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']);
|
||||
@ -403,7 +406,11 @@ function deriveProgress(
|
||||
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||
return {
|
||||
mode: 'ratio',
|
||||
label: `阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
|
||||
label: pickAppText(
|
||||
locale,
|
||||
`阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
|
||||
`Stage ${Math.min(stageValue, stageMax)} / ${stageMax}`
|
||||
),
|
||||
value: stageValue,
|
||||
max: stageMax,
|
||||
stageLabel,
|
||||
@ -414,7 +421,11 @@ function deriveProgress(
|
||||
if (taskRuns.length > 0) {
|
||||
return {
|
||||
mode: 'ratio',
|
||||
label: `已完成子任务 ${doneRuns} / ${taskRuns.length}`,
|
||||
label: pickAppText(
|
||||
locale,
|
||||
`已完成子任务 ${doneRuns} / ${taskRuns.length}`,
|
||||
`Subtasks completed ${doneRuns} / ${taskRuns.length}`
|
||||
),
|
||||
value: doneRuns,
|
||||
max: taskRuns.length,
|
||||
stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null,
|
||||
@ -423,7 +434,7 @@ function deriveProgress(
|
||||
|
||||
return {
|
||||
mode: 'status',
|
||||
label: '等待任务数据',
|
||||
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||
value: null,
|
||||
max: null,
|
||||
stageLabel,
|
||||
@ -433,6 +444,7 @@ function deriveProgress(
|
||||
function buildAlerts(
|
||||
taskViews: OfficeTaskView[],
|
||||
now: number,
|
||||
locale: AppLocale,
|
||||
): OfficeAlertView[] {
|
||||
const alerts: OfficeAlertView[] = [];
|
||||
|
||||
@ -441,7 +453,7 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `error:${task.runId}`,
|
||||
level: 'error',
|
||||
title: `${task.actorName} 执行失败`,
|
||||
title: pickAppText(locale, `${task.actorName} 执行失败`, `${task.actorName} failed`),
|
||||
description: task.errorText,
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
@ -451,8 +463,8 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `blocked:${task.runId}`,
|
||||
level: 'warn',
|
||||
title: `${task.actorName} 长时间等待`,
|
||||
description: '该任务长时间无更新,可能存在阻塞。',
|
||||
title: pickAppText(locale, `${task.actorName} 长时间等待`, `${task.actorName} has been waiting for a while`),
|
||||
description: pickAppText(locale, '该任务长时间无更新,可能存在阻塞。', 'This task has not updated for a while and may be blocked.'),
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
createdAt: task.updatedAt,
|
||||
@ -463,8 +475,8 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `stale:${task.runId}`,
|
||||
level: 'warn',
|
||||
title: `${task.actorName} 等待时间偏长`,
|
||||
description: '该任务仍处于等待态,建议查看详情确认依赖是否卡住。',
|
||||
title: pickAppText(locale, `${task.actorName} 等待时间偏长`, `${task.actorName} has been waiting longer than expected`),
|
||||
description: pickAppText(locale, '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', 'This task is still waiting. Check the details to confirm whether a dependency is stuck.'),
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
createdAt: task.updatedAt,
|
||||
@ -476,18 +488,18 @@ function buildAlerts(
|
||||
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'];
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
label: zoneLabel(id),
|
||||
label: zoneLabel(id, locale),
|
||||
memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId),
|
||||
taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId),
|
||||
tone: zoneTone(id),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>): OfficeAssignmentView[] {
|
||||
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>, locale: AppLocale): OfficeAssignmentView[] {
|
||||
return taskRuns
|
||||
.filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0)
|
||||
.map((run) => {
|
||||
@ -497,7 +509,7 @@ function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, Proce
|
||||
ownerActorName: run.actor_name,
|
||||
assigneeRunIds: children.map((item) => item.run_id),
|
||||
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);
|
||||
}
|
||||
|
||||
export function officeTaskStatusLabel(status: OfficeTaskStatus): string {
|
||||
if (status === 'queued') return '排队中';
|
||||
if (status === 'running') return '进行中';
|
||||
if (status === 'waiting') return '等待中';
|
||||
if (status === 'blocked') return '阻塞';
|
||||
if (status === 'done') return '已完成';
|
||||
if (status === 'error') return '失败';
|
||||
return '已取消';
|
||||
export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocale = getCurrentAppLocale()): string {
|
||||
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
|
||||
if (status === 'running') return pickAppText(locale, '进行中', 'In Progress');
|
||||
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');
|
||||
return pickAppText(locale, '已取消', 'Cancelled');
|
||||
}
|
||||
|
||||
export function buildOfficeView(
|
||||
taskId: string,
|
||||
input: BuildOfficeInput,
|
||||
locale: AppLocale = getCurrentAppLocale(),
|
||||
): OfficeView | null {
|
||||
const { sessions, processRuns, processEvents, processArtifacts } = input;
|
||||
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 updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
|
||||
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) ?? [])
|
||||
.filter((child) => taskRunIds.has(child.run_id))
|
||||
.map((child) => child.run_id);
|
||||
@ -560,7 +573,7 @@ export function buildOfficeView(
|
||||
finishedAt: run.finished_at ?? null,
|
||||
childTaskIds,
|
||||
artifactCount: (artifactsByRun.get(run.run_id) ?? []).length,
|
||||
errorText: deriveErrorText(run, runEvents),
|
||||
errorText: deriveErrorText(run, runEvents, locale),
|
||||
isRoot: run.run_id === rootRun.run_id,
|
||||
};
|
||||
})
|
||||
@ -620,9 +633,9 @@ export function buildOfficeView(
|
||||
rootRun.started_at,
|
||||
]) ?? rootRun.started_at;
|
||||
const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now);
|
||||
const alerts = buildAlerts(taskViews, now);
|
||||
const progress = deriveProgress(rootRun, taskRuns, taskViews);
|
||||
const sourceSessionLabel = getSessionLabel(sessions, sessionId);
|
||||
const alerts = buildAlerts(taskViews, now, locale);
|
||||
const progress = deriveProgress(rootRun, taskRuns, taskViews, locale);
|
||||
const sourceSessionLabel = getSessionLabel(sessions, sessionId, locale);
|
||||
const createdAt = rootRun.started_at;
|
||||
const finishedAt = rootRun.finished_at ?? null;
|
||||
const durationStart = toTime(createdAt);
|
||||
@ -636,7 +649,7 @@ export function buildOfficeView(
|
||||
officeId: rootRun.run_id,
|
||||
taskId: rootRun.run_id,
|
||||
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,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@ -645,7 +658,7 @@ export function buildOfficeView(
|
||||
sourceSessionLabel,
|
||||
rootRunId: rootRun.run_id,
|
||||
rootActorName: rootRun.actor_name,
|
||||
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus),
|
||||
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus, locale),
|
||||
progress,
|
||||
stats: {
|
||||
totalRuns: taskRuns.length,
|
||||
@ -657,25 +670,25 @@ export function buildOfficeView(
|
||||
artifactCount: taskArtifacts.length,
|
||||
},
|
||||
alerts,
|
||||
zones: buildZones(members, taskViews),
|
||||
zones: buildZones(members, taskViews, locale),
|
||||
members,
|
||||
tasks: taskViews,
|
||||
assignments: buildAssignments(taskRuns, childrenMap),
|
||||
assignments: buildAssignments(taskRuns, childrenMap, locale),
|
||||
detailRunIds: taskViews.map((task) => task.runId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOfficeTaskList(
|
||||
input: BuildOfficeInput & { sessionId?: string | null },
|
||||
locale: AppLocale = getCurrentAppLocale(),
|
||||
): OfficeTaskListItem[] {
|
||||
const rootRuns = findRootRuns(input.processRuns);
|
||||
const filteredRoots = input.sessionId
|
||||
? rootRuns.filter((run) => run.session_id === input.sessionId)
|
||||
: rootRuns;
|
||||
|
||||
return filteredRoots
|
||||
.map((rootRun) => buildOfficeView(rootRun.run_id, input))
|
||||
const offices = rootRuns
|
||||
.map((rootRun) => buildOfficeView(rootRun.run_id, input, locale))
|
||||
.filter((office): office is OfficeView => office !== null)
|
||||
.filter((office) => !input.sessionId || office.sessionId === input.sessionId);
|
||||
|
||||
return offices
|
||||
.map((office) => ({
|
||||
officeId: office.officeId,
|
||||
taskId: office.taskId,
|
||||
|
||||
52
app-instance/frontend/lib/store.test.ts
Normal file
52
app-instance/frontend/lib/store.test.ts
Normal 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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -231,18 +231,21 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_status') {
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title:
|
||||
nextRuns.find((item) => item.run_id === event.run_id)?.title ||
|
||||
event.actor_name,
|
||||
current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: event.status,
|
||||
started_at:
|
||||
nextRuns.find((item) => item.run_id === event.run_id)?.started_at ||
|
||||
event.created_at,
|
||||
current?.started_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);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
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_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: current?.status || 'running',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@ -264,12 +271,15 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
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_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: current?.status || 'running',
|
||||
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);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
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_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: event.status,
|
||||
started_at: current?.started_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);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
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_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: 'cancelled',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
finished_at: event.created_at,
|
||||
summary: current?.summary ?? '已取消',
|
||||
summary: current?.summary ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -201,7 +201,6 @@ export interface UiAgentDescriptor {
|
||||
tags: string[];
|
||||
aliases: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
support_group: boolean;
|
||||
support_streaming: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const locale = normalizePortalLocale(
|
||||
request.cookies.get('nanobot_locale')?.value ||
|
||||
request.headers.get('accept-language')
|
||||
);
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
@ -27,7 +33,9 @@ export async function POST(request: NextRequest) {
|
||||
const password = body.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<{
|
||||
@ -44,7 +52,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(normalizeTokenResponse(response, routing));
|
||||
} catch (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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const locale = normalizePortalLocale(
|
||||
request.cookies.get('nanobot_locale')?.value ||
|
||||
request.headers.get('accept-language')
|
||||
);
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
@ -29,7 +35,9 @@ export async function POST(request: NextRequest) {
|
||||
const password = body.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', {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { PortalI18nProvider } from '@/lib/i18n/provider';
|
||||
import { getServerPortalLocale } from '@/lib/i18n/server';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Boardware Agent Sandbox Auth Portal',
|
||||
description: 'Dedicated login and registration portal for Boardware Genius containers.',
|
||||
description: 'Boardware Agent Sandbox Auth Portal',
|
||||
icons: {
|
||||
icon: '/boardware-logo.jpg',
|
||||
},
|
||||
@ -14,9 +16,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = getServerPortalLocale();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<PortalI18nProvider initialLocale={locale}>{children}</PortalI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,9 +5,13 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client';
|
||||
import { pickPortalText } from '@/lib/i18n/core';
|
||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { locale } = usePortalI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams?.get('next') || '/';
|
||||
|
||||
@ -25,7 +29,7 @@ export default function LoginPage() {
|
||||
const response = await login(username, password);
|
||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请稍后重试');
|
||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '登录失败,请稍后重试', 'Sign-in failed. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -33,6 +37,9 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<section className="portal-shell">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-logo-lockup">
|
||||
@ -47,47 +54,55 @@ export default function LoginPage() {
|
||||
<div className="portal-kicker">Auth Portal</div>
|
||||
<h1 className="portal-title">Boardware Agent Sandbox</h1>
|
||||
<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>
|
||||
<div className="portal-notes">
|
||||
<div className="portal-note">
|
||||
<strong>容器边界</strong>
|
||||
登录注册先经过独立 auth portal,再跳到专属实例。一用户一套前后端容器不变。
|
||||
<strong>{pickPortalText(locale, '容器边界', 'Container boundary')}</strong>
|
||||
{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 className="portal-note">
|
||||
<strong>目标页面</strong>
|
||||
当前登录完成后将回到:<code>{nextPath}</code>
|
||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
||||
{pickPortalText(locale, '当前登录完成后将回到:', 'After sign-in you will return to:')} <code>{nextPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-panel">
|
||||
<div className="auth-card">
|
||||
<h1>登录</h1>
|
||||
<p>输入已有账号,认证完成后直接进入目标容器前端。</p>
|
||||
<h1>{pickPortalText(locale, '登录', 'Sign In')}</h1>
|
||||
<p>{pickPortalText(locale, '输入已有账号,认证完成后直接进入目标容器前端。', 'Use an existing account and continue straight into the target runtime UI.')}</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="例如:bwgdi"
|
||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="输入密码"
|
||||
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -95,12 +110,14 @@ export default function LoginPage() {
|
||||
<div className="error-text">{error}</div>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录并进入容器'}
|
||||
{loading
|
||||
? pickPortalText(locale, '登录中...', 'Signing in...')
|
||||
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
@ -5,9 +5,13 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client';
|
||||
import { pickPortalText } from '@/lib/i18n/core';
|
||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { locale } = usePortalI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams?.get('next') || '/mcp';
|
||||
|
||||
@ -25,12 +29,12 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('两次输入的密码不一致');
|
||||
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
|
||||
}
|
||||
const response = await register(username, email, password);
|
||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败,请稍后重试');
|
||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -38,6 +42,9 @@ export default function RegisterPage() {
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<section className="portal-shell">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-logo-lockup">
|
||||
@ -52,72 +59,80 @@ export default function RegisterPage() {
|
||||
<div className="portal-kicker">Auth Portal</div>
|
||||
<h1 className="portal-title">Create Runtime</h1>
|
||||
<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>
|
||||
<div className="portal-notes">
|
||||
<div className="portal-note">
|
||||
<strong>注册结果</strong>
|
||||
AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化,auth portal 最后把你转交到该实例前端。
|
||||
<strong>{pickPortalText(locale, '注册结果', 'Provisioning result')}</strong>
|
||||
{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 className="portal-note">
|
||||
<strong>目标页面</strong>
|
||||
当前注册完成后将回到:<code>{nextPath}</code>
|
||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
||||
{pickPortalText(locale, '当前注册完成后将回到:', 'After sign-up you will return to:')} <code>{nextPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-panel">
|
||||
<div className="auth-card">
|
||||
<h1>注册</h1>
|
||||
<p>为当前容器创建登录账号,并完成 backend 身份初始化。</p>
|
||||
<h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
|
||||
<p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="例如:bwgdi"
|
||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="例如:steven@example.com"
|
||||
placeholder={pickPortalText(locale, '例如:steven@example.com', 'Example: steven@example.com')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="设置密码"
|
||||
placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="confirmPassword">确认密码</label>
|
||||
<label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="再次输入密码"
|
||||
placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -125,16 +140,18 @@ export default function RegisterPage() {
|
||||
<div className="error-text">{error}</div>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册并进入容器'}
|
||||
{loading
|
||||
? pickPortalText(locale, '注册中...', 'Creating account...')
|
||||
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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 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>
|
||||
|
||||
32
auth-portal/src/components/LanguageSwitcher.tsx
Normal file
32
auth-portal/src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
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> {
|
||||
const locale = getCurrentPortalLocale();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@ -52,13 +54,13 @@ async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQ
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`接口错误 ${response.status}: ${detail}`);
|
||||
throw new Error(`${pickPortalText(locale, '接口错误', 'API error')} ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
throw new Error(pickPortalText(locale, '请求超时', 'Request timed out'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@ -81,13 +83,14 @@ export async function register(username: string, email: string, password: string
|
||||
}
|
||||
|
||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||
const locale = getCurrentPortalLocale();
|
||||
const frontendBaseUrl = getFrontendBaseUrl(response);
|
||||
if (!frontendBaseUrl) {
|
||||
throw new Error('后端未返回目标前端地址');
|
||||
throw new Error(pickPortalText(locale, '后端未返回目标前端地址', 'Backend did not return a target frontend URL'));
|
||||
}
|
||||
const handoffCode = response.handoff_code?.trim();
|
||||
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);
|
||||
|
||||
72
auth-portal/src/lib/i18n/core.ts
Normal file
72
auth-portal/src/lib/i18n/core.ts
Normal 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;
|
||||
}
|
||||
57
auth-portal/src/lib/i18n/provider.tsx
Normal file
57
auth-portal/src/lib/i18n/provider.tsx
Normal 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;
|
||||
}
|
||||
17
auth-portal/src/lib/i18n/server.ts
Normal file
17
auth-portal/src/lib/i18n/server.ts
Normal 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
Reference in New Issue
Block a user