Compare commits
13 Commits
6e9e74d1ee
...
33a9845566
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a9845566 | |||
| 55b39563a0 | |||
| 41ac87e322 | |||
| 542b23ef6e | |||
| 9002d1206f | |||
| dd9f40b38c | |||
| 96562877cc | |||
| f58a57e5b8 | |||
| 362aae9b12 | |||
| 29d175222d | |||
| 2e4f8541ee | |||
| a1164dc49a | |||
| 7b638b083a |
145
app-instance/backend/agents/registry.json
Normal file
145
app-instance/backend/agents/registry.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"capabilities": [
|
||||
"research",
|
||||
"analysis",
|
||||
"source review",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756341+00:00",
|
||||
"description": "Finds facts, references, constraints, and implementation options.",
|
||||
"display_name": "Researcher",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "researcher",
|
||||
"priority": 50,
|
||||
"provider_name": null,
|
||||
"role": "research",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
||||
"tags": [
|
||||
"planning",
|
||||
"research"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756349+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "implementer",
|
||||
"capabilities": [
|
||||
"implementation",
|
||||
"coding",
|
||||
"refactor",
|
||||
"integration"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756351+00:00",
|
||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
||||
"display_name": "Implementer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "implementer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "implementation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
||||
"tags": [
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756353+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "reviewer",
|
||||
"capabilities": [
|
||||
"review",
|
||||
"quality",
|
||||
"risk",
|
||||
"verification"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756355+00:00",
|
||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
||||
"display_name": "Reviewer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "reviewer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "review",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756356+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "tester",
|
||||
"capabilities": [
|
||||
"testing",
|
||||
"verification",
|
||||
"regression",
|
||||
"qa"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756358+00:00",
|
||||
"description": "Designs and executes verification checks for task outputs.",
|
||||
"display_name": "Tester",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "tester",
|
||||
"priority": 40,
|
||||
"provider_name": null,
|
||||
"role": "testing",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
||||
"tags": [
|
||||
"test",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756358+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "documenter",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"explanation",
|
||||
"migration notes",
|
||||
"release notes"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756360+00:00",
|
||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
||||
"display_name": "Documenter",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "documenter",
|
||||
"priority": 35,
|
||||
"provider_name": null,
|
||||
"role": "documentation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
||||
"tags": [
|
||||
"docs",
|
||||
"communication"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756360+00:00"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@ -44,6 +44,7 @@ from beaver.tools.builtins import (
|
||||
SpawnTool,
|
||||
SessionSearchTool,
|
||||
SkillManageTool,
|
||||
SkillViewTool,
|
||||
SkillsListTool,
|
||||
TerminalTool,
|
||||
TodoTool,
|
||||
@ -220,16 +221,17 @@ class EngineLoader:
|
||||
ObjectBackedTool(WriteFileTool()),
|
||||
ObjectBackedTool(PatchFileTool()),
|
||||
ObjectBackedTool(WebFetchTool()),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||
SkillManageTool(),
|
||||
CronTool(),
|
||||
]
|
||||
|
||||
@ -48,7 +48,7 @@ class AgentProfile:
|
||||
name: str = "default"
|
||||
system_prompt: str = ""
|
||||
default_model: str = "gpt-4.1-mini"
|
||||
max_tokens: int = 4096
|
||||
max_tokens: int | None = None
|
||||
max_context_messages: int = 1000
|
||||
temperature: float = 0.2
|
||||
max_tool_iterations: int = 30
|
||||
@ -89,6 +89,7 @@ class AgentLoop:
|
||||
self.loaded: EngineLoadResult | None = None
|
||||
self.runtime_services: dict[str, Any] = {}
|
||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||
self._active_direct_task: asyncio.Task[Any] | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
|
||||
@ -130,6 +131,8 @@ class AgentLoop:
|
||||
if item.future.cancelled():
|
||||
continue
|
||||
|
||||
previous_direct_task = self._active_direct_task
|
||||
self._active_direct_task = asyncio.current_task()
|
||||
try:
|
||||
result = await self._process_direct_impl(item.task, **item.kwargs)
|
||||
except asyncio.CancelledError:
|
||||
@ -142,6 +145,8 @@ class AgentLoop:
|
||||
else:
|
||||
if not item.future.done():
|
||||
item.future.set_result(result)
|
||||
finally:
|
||||
self._active_direct_task = previous_direct_task
|
||||
finally:
|
||||
if self._run_queue is not None:
|
||||
while True:
|
||||
@ -183,6 +188,9 @@ class AgentLoop:
|
||||
if self._stop_requested:
|
||||
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
||||
|
||||
if asyncio.current_task() is self._active_direct_task:
|
||||
return await self._process_direct_impl(task, **kwargs)
|
||||
|
||||
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
||||
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
||||
return await future
|
||||
@ -363,7 +371,7 @@ class AgentLoop:
|
||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
||||
resolved_max_tokens = max_tokens or self.profile.max_tokens
|
||||
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
|
||||
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
||||
resolved_max_tool_iterations = (
|
||||
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
|
||||
@ -892,7 +900,7 @@ class AgentLoop:
|
||||
provider: Any,
|
||||
messages: list[dict[str, Any]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> str:
|
||||
|
||||
@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
|
||||
"model": model or self.default_model,
|
||||
"system": system_prompt or "",
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
resolved_max_tokens = (
|
||||
_default_max_tokens_for_model(model or self.default_model)
|
||||
if max_tokens is None
|
||||
else max(1, max_tokens)
|
||||
)
|
||||
kwargs["max_tokens"] = resolved_max_tokens
|
||||
if tools:
|
||||
kwargs["tools"] = _convert_tools(tools)
|
||||
|
||||
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
|
||||
return self.default_model
|
||||
|
||||
|
||||
def _default_max_tokens_for_model(model: str) -> int:
|
||||
"""Return a conservative native output ceiling for Anthropic Messages."""
|
||||
|
||||
normalized = model.lower().replace("_", "-")
|
||||
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
|
||||
return 64_000
|
||||
if "haiku" in normalized:
|
||||
return 4_096
|
||||
return 8_192
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
@ -88,7 +88,7 @@ class LLMProvider(ABC):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.default_model,
|
||||
"messages": self.sanitize_empty_content(messages),
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if tools:
|
||||
kwargs.update(tools=tools, tool_choice="auto")
|
||||
try:
|
||||
|
||||
@ -197,7 +197,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -210,10 +210,11 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"messages": sanitized_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
"timeout": self.request_timeout_seconds or 45.0,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
|
||||
@ -86,18 +86,25 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
|
||||
model=_string(defaults.get("model") or data.get("model")),
|
||||
provider=_string(defaults.get("provider") or data.get("provider")),
|
||||
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
||||
max_tokens=_int(_first_config_value(
|
||||
defaults.get("maxTokens"),
|
||||
defaults.get("max_tokens"),
|
||||
data.get("maxTokens"),
|
||||
data.get("max_tokens"),
|
||||
)),
|
||||
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
|
||||
max_context_messages=_int(
|
||||
defaults.get("maxContextMessages")
|
||||
or defaults.get("max_context_messages")
|
||||
or data.get("maxContextMessages")
|
||||
or data.get("max_context_messages")
|
||||
),
|
||||
max_tool_iterations=_int(
|
||||
defaults.get("maxToolIterations")
|
||||
or defaults.get("max_tool_iterations")
|
||||
or data.get("maxToolIterations")
|
||||
or data.get("max_tool_iterations")
|
||||
),
|
||||
max_tool_iterations=_int(_first_config_value(
|
||||
defaults.get("maxToolIterations"),
|
||||
defaults.get("max_tool_iterations"),
|
||||
data.get("maxToolIterations"),
|
||||
data.get("max_tool_iterations"),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -204,6 +211,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_config_value(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@ -25,6 +25,8 @@ class AgentDefaultsConfig:
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
embedding_model: str | None = None
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
max_context_messages: int | None = None
|
||||
max_tool_iterations: int | None = None
|
||||
|
||||
|
||||
@ -51,6 +51,8 @@ from .schemas import (
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebStatusResponse,
|
||||
@ -595,6 +597,38 @@ def create_app(
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
|
||||
|
||||
@app.post("/api/agent-config", response_model=WebAgentConfigResponse)
|
||||
async def update_agent_config(
|
||||
request: Request,
|
||||
payload: WebAgentConfigRequest,
|
||||
) -> WebAgentConfigResponse:
|
||||
if payload.max_tokens is not None and payload.max_tokens <= 0:
|
||||
raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
|
||||
if payload.temperature < 0 or payload.temperature > 2:
|
||||
raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
|
||||
if payload.max_tool_iterations < 0:
|
||||
raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")
|
||||
|
||||
agent_service = get_agent_service(request)
|
||||
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||
raw = _read_config_json(config_path)
|
||||
agents = _ensure_dict(raw, "agents")
|
||||
defaults = _ensure_dict(agents, "defaults")
|
||||
|
||||
if payload.max_tokens is None:
|
||||
defaults.pop("maxTokens", None)
|
||||
defaults.pop("max_tokens", None)
|
||||
else:
|
||||
defaults["maxTokens"] = payload.max_tokens
|
||||
defaults.pop("max_tokens", None)
|
||||
defaults["temperature"] = payload.temperature
|
||||
defaults["maxToolIterations"] = payload.max_tool_iterations
|
||||
defaults.pop("max_tool_iterations", None)
|
||||
|
||||
_write_config_json(config_path, raw)
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebAgentConfigResponse(ok=True)
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions(request: Request) -> list[dict[str, Any]]:
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
|
||||
@ -8,6 +8,8 @@ from .chat import (
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebProviderTarget,
|
||||
@ -22,6 +24,8 @@ __all__ = [
|
||||
"WebChatRequest",
|
||||
"WebChatResponse",
|
||||
"WebErrorResponse",
|
||||
"WebAgentConfigRequest",
|
||||
"WebAgentConfigResponse",
|
||||
"WebProviderConfigRequest",
|
||||
"WebProviderConfigResponse",
|
||||
"WebProviderTarget",
|
||||
|
||||
@ -139,6 +139,20 @@ class WebProviderConfigResponse(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class WebAgentConfigRequest(BaseModel):
|
||||
"""Agent runtime defaults update from the settings page."""
|
||||
|
||||
max_tokens: int | None = None
|
||||
temperature: float
|
||||
max_tool_iterations: int
|
||||
|
||||
|
||||
class WebAgentConfigResponse(BaseModel):
|
||||
"""Agent runtime defaults update result."""
|
||||
|
||||
ok: bool
|
||||
|
||||
|
||||
class WebStatusResponse(BaseModel):
|
||||
"""Web 宿主层状态响应。"""
|
||||
|
||||
|
||||
@ -68,6 +68,14 @@ class AgentService:
|
||||
|
||||
def _apply_configured_profile_defaults(self) -> None:
|
||||
defaults = self.loader.config.agents_defaults
|
||||
self.profile.max_tokens = None
|
||||
self.profile.temperature = 0.2
|
||||
self.profile.max_context_messages = 1000
|
||||
self.profile.max_tool_iterations = 30
|
||||
if defaults.max_tokens is not None:
|
||||
self.profile.max_tokens = max(1, defaults.max_tokens)
|
||||
if defaults.temperature is not None:
|
||||
self.profile.temperature = defaults.temperature
|
||||
if defaults.max_context_messages is not None:
|
||||
self.profile.max_context_messages = max(1, defaults.max_context_messages)
|
||||
if defaults.max_tool_iterations is not None:
|
||||
|
||||
@ -50,10 +50,11 @@ class SessionProcessProjector:
|
||||
|
||||
for record in records:
|
||||
payload = dict(record.event_payload or {})
|
||||
task_id = payload.get("task_id")
|
||||
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
|
||||
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
|
||||
if not task_id:
|
||||
continue
|
||||
attempt_index = int(payload.get("attempt_index") or 1)
|
||||
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
|
||||
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
|
||||
created_at = _timestamp(record.timestamp)
|
||||
root = runs.setdefault(
|
||||
@ -73,15 +74,70 @@ class SessionProcessProjector:
|
||||
},
|
||||
)
|
||||
|
||||
if record.event_type == "task_execution_planned":
|
||||
if record.event_type == "assistant_message_added" and record.tool_calls:
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
for index, tool_call in enumerate(record.tool_calls):
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_name = _tool_call_name(tool_call)
|
||||
add_event(
|
||||
event_id=f"{_event_id(record, 'tool-call')}:{index}",
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_started",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=f"Calling tool: {tool_name}.",
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_call",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call.get("id"),
|
||||
"arguments": _tool_call_arguments(tool_call),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "tool_result_recorded":
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
|
||||
add_event(
|
||||
event_id=_event_id(record, "tool-result"),
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_finished",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=_truncate(str(record.content or payload.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status="done" if payload.get("success", True) else "error",
|
||||
metadata={
|
||||
**dict(payload),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_result",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": record.tool_call_id,
|
||||
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_execution_planned":
|
||||
plan_mode = payload.get("plan_mode") or "single"
|
||||
strategy = payload.get("strategy") or "single"
|
||||
node_ids = payload.get("node_ids") or []
|
||||
root["title"] = f"{payload.get('plan_mode', 'single')} plan: {strategy}"
|
||||
root["title"] = f"{plan_mode} plan: {strategy}"
|
||||
root["summary"] = payload.get("reason") or ""
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"plan_mode": payload.get("plan_mode"),
|
||||
"strategy": payload.get("strategy"),
|
||||
"plan_mode": plan_mode,
|
||||
"strategy": strategy,
|
||||
"node_ids": node_ids,
|
||||
"skill_queries": payload.get("skill_queries") or [],
|
||||
"selected_skill_names": payload.get("selected_skill_names") or [],
|
||||
@ -92,36 +148,65 @@ class SessionProcessProjector:
|
||||
add_event(
|
||||
event_id=_event_id(record, "planned"),
|
||||
run_id=root_run_id,
|
||||
kind="run_started",
|
||||
kind="task_planned",
|
||||
actor_type="system",
|
||||
actor_id="task",
|
||||
actor_name="Task Planner",
|
||||
text=f"Planned {payload.get('plan_mode')} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
text=f"Beaver planned {plan_mode} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata=root["metadata"],
|
||||
metadata={
|
||||
**root["metadata"],
|
||||
"timeline_type": "plan",
|
||||
"user_summary": f"Beaver will use {plan_mode} execution for this task.",
|
||||
},
|
||||
)
|
||||
selected_skill_names = [
|
||||
str(item)
|
||||
for item in payload.get("selected_skill_names") or []
|
||||
if str(item).strip()
|
||||
]
|
||||
if selected_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "skills"),
|
||||
run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": selected_skill_names,
|
||||
"reason": payload.get("reason") or "Selected from task planning context.",
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
|
||||
team_success = bool(payload.get("team_success"))
|
||||
root["status"] = "running"
|
||||
team_run_ids = payload.get("team_run_ids") or []
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"team_success": team_success,
|
||||
"team_run_ids": payload.get("team_run_ids") or [],
|
||||
"team_run_ids": team_run_ids,
|
||||
"team_error": payload.get("error"),
|
||||
}
|
||||
add_event(
|
||||
event_id=_event_id(record, "team"),
|
||||
run_id=root_run_id,
|
||||
kind="run_status",
|
||||
kind="agent_team_created",
|
||||
actor_type="system",
|
||||
actor_id="team",
|
||||
actor_name="Task Team",
|
||||
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
||||
created_at=created_at,
|
||||
status="done" if team_success else "error",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
|
||||
)
|
||||
node_results = payload.get("node_results") or []
|
||||
for item in node_results:
|
||||
@ -192,20 +277,26 @@ class SessionProcessProjector:
|
||||
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
||||
run_id=str(node_run_id),
|
||||
parent_run_id=root_run_id,
|
||||
kind="run_finished",
|
||||
kind="agent_finished",
|
||||
actor_type="agent",
|
||||
actor_id=str(item.get("node_id") or "sub-agent"),
|
||||
actor_name=str(item.get("node_id") or "Sub-agent"),
|
||||
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
metadata=dict(item),
|
||||
metadata={
|
||||
**dict(item),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "agent_progress",
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_synthesis_completed":
|
||||
main_run_id = str(payload.get("main_run_id") or "")
|
||||
if main_run_id:
|
||||
run_record = run_records.get(main_run_id)
|
||||
activated_skill_names = _activated_skill_names(run_record)
|
||||
runs[main_run_id] = {
|
||||
"run_id": main_run_id,
|
||||
"parent_run_id": root_run_id,
|
||||
@ -219,8 +310,32 @@ class SessionProcessProjector:
|
||||
"started_at": run_record.started_at if run_record is not None else created_at,
|
||||
"finished_at": run_record.ended_at if run_record is not None else created_at,
|
||||
"summary": _truncate(run_record.task_text if run_record is not None else ""),
|
||||
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
|
||||
"metadata": {
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"skill_names": activated_skill_names,
|
||||
},
|
||||
}
|
||||
if activated_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis-skills"),
|
||||
run_id=main_run_id,
|
||||
parent_run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": activated_skill_names,
|
||||
"activation_reasons": _activated_skill_reasons(run_record),
|
||||
},
|
||||
)
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis"),
|
||||
run_id=main_run_id,
|
||||
@ -242,14 +357,14 @@ class SessionProcessProjector:
|
||||
event_id=_event_id(record, "evidence"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="run_status",
|
||||
kind="task_result_ready",
|
||||
actor_type="system",
|
||||
actor_id="evidence-recorder",
|
||||
actor_name="Evidence",
|
||||
text="Task evidence was recorded; waiting for user acceptance.",
|
||||
text="The task result is ready for user acceptance.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "result"},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_acceptance_recorded":
|
||||
@ -267,14 +382,14 @@ class SessionProcessProjector:
|
||||
event_id=_event_id(record, "acceptance"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="run_status",
|
||||
kind="task_acceptance_recorded",
|
||||
actor_type="user",
|
||||
actor_id="user-acceptance",
|
||||
actor_name="User Acceptance",
|
||||
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "acceptance"},
|
||||
)
|
||||
|
||||
return {
|
||||
@ -300,3 +415,49 @@ def _truncate(text: str, limit: int = 800) -> str:
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1] + "..."
|
||||
|
||||
|
||||
def _activated_skill_names(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
names = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
|
||||
if skill_name:
|
||||
names.append(skill_name)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
reasons = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
|
||||
if reason:
|
||||
reasons.append(reason)
|
||||
return reasons
|
||||
|
||||
|
||||
def _tool_call_name(tool_call: dict[str, Any]) -> str:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict):
|
||||
name = function_payload.get("name")
|
||||
if name:
|
||||
return str(name)
|
||||
for key in ("name", "tool_name"):
|
||||
value = tool_call.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return "tool"
|
||||
|
||||
|
||||
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict) and "arguments" in function_payload:
|
||||
return function_payload.get("arguments")
|
||||
if "arguments" in tool_call:
|
||||
return tool_call.get("arguments")
|
||||
if "args" in tool_call:
|
||||
return tool_call.get("args")
|
||||
return None
|
||||
|
||||
@ -51,7 +51,7 @@ class WebFetchTool:
|
||||
try:
|
||||
safe_url = _safe_url(url)
|
||||
limit = max(1000, min(int(max_chars or 12000), 50000))
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(
|
||||
safe_url,
|
||||
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
|
||||
@ -96,7 +96,7 @@ class WebSearchTool:
|
||||
raise ValueError("query is required")
|
||||
bounded = max(1, min(int(limit or 5), 10))
|
||||
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
|
||||
|
||||
|
||||
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
|
||||
return AgentRunResult(
|
||||
session_id="web:test",
|
||||
run_id=run_id,
|
||||
output_text=output_text,
|
||||
finish_reason="stop",
|
||||
tool_iterations=0,
|
||||
)
|
||||
|
||||
|
||||
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
|
||||
async def run_case() -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
|
||||
calls.append(task)
|
||||
if task == "outer":
|
||||
return await loop.submit_direct("inner", session_id="web:test")
|
||||
return _run_result(task, "inner completed")
|
||||
|
||||
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
|
||||
|
||||
loop_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
|
||||
finally:
|
||||
await loop.stop()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(loop_task, timeout=1)
|
||||
if not loop_task.done():
|
||||
loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await loop_task
|
||||
|
||||
assert result.output_text == "inner completed"
|
||||
assert calls == ["outer", "inner"]
|
||||
|
||||
asyncio.run(run_case())
|
||||
@ -1,10 +1,12 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
from beaver.foundation.config import load_config
|
||||
from beaver.interfaces.web.app import _reload_agent_config
|
||||
from beaver.interfaces.web.app import create_app, _reload_agent_config
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@ -161,6 +163,88 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"maxTokens": 12345,
|
||||
"temperature": 0.4,
|
||||
"maxToolIterations": 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
service = AgentService(config_path=config_path)
|
||||
|
||||
assert config.agents_defaults.max_tokens == 12345
|
||||
assert config.agents_defaults.temperature == 0.4
|
||||
assert config.agents_defaults.max_tool_iterations == 9
|
||||
assert service.profile.max_tokens == 12345
|
||||
assert service.profile.temperature == 0.4
|
||||
assert service.profile.max_tool_iterations == 9
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
|
||||
)
|
||||
status = client.get("/api/status")
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
defaults = saved["agents"]["defaults"]
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert defaults["maxTokens"] == 8192
|
||||
assert defaults["temperature"] == 0.6
|
||||
assert defaults["maxToolIterations"] == 12
|
||||
assert service.profile.max_tokens == 8192
|
||||
assert service.profile.temperature == 0.6
|
||||
assert service.profile.max_tool_iterations == 12
|
||||
assert status.json()["max_tokens"] == 8192
|
||||
assert status.json()["temperature"] == 0.6
|
||||
assert status.json()["max_tool_iterations"] == 12
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert config.agents_defaults.max_tokens is None
|
||||
assert config.agents_defaults.temperature == 0
|
||||
assert config.agents_defaults.max_tool_iterations == 0
|
||||
assert service.profile.max_tokens is None
|
||||
assert service.profile.temperature == 0
|
||||
assert service.profile.max_tool_iterations == 0
|
||||
service.close()
|
||||
|
||||
|
||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
bundle = make_provider_bundle(
|
||||
model="qwen-plus",
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"cron-scheduler": ["cron"],
|
||||
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
|
||||
"memory-management": ["memory"],
|
||||
"outlook-mail": [
|
||||
"mcp_outlook_mcp_mail_list_folders",
|
||||
"mcp_outlook_mcp_mail_list_messages",
|
||||
"mcp_outlook_mcp_mail_search_messages",
|
||||
"mcp_outlook_mcp_mail_get_message",
|
||||
"mcp_outlook_mcp_mail_send_email",
|
||||
"mcp_outlook_mcp_mail_reply_to_message",
|
||||
"mcp_outlook_mcp_mail_forward_message",
|
||||
"mcp_outlook_mcp_mail_move_message",
|
||||
"mcp_outlook_mcp_mail_delta_sync",
|
||||
"mcp_outlook_mcp_calendar_list_events",
|
||||
"mcp_outlook_mcp_calendar_create_event",
|
||||
"mcp_outlook_mcp_calendar_update_event",
|
||||
"mcp_outlook_mcp_calendar_get_schedule",
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
}
|
||||
|
||||
|
||||
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
assert "skill_view" in loaded.tools
|
||||
assert loaded.tool_registry is not None
|
||||
assert loaded.tool_registry.get("skill_view") is not None
|
||||
finally:
|
||||
loaded.close()
|
||||
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.loop import AgentProfile
|
||||
from beaver.engine.providers.anthropic import AnthropicProvider
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
|
||||
|
||||
def test_agent_profile_uses_provider_output_default() -> None:
|
||||
assert AgentProfile().max_tokens is None
|
||||
|
||||
|
||||
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="ok", tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
|
||||
async def run_case():
|
||||
provider = LiteLLMProvider(default_model="openai/gpt-test")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert "max_tokens" not in captured_kwargs
|
||||
|
||||
|
||||
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
class FakeMessages:
|
||||
async def create(self, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
usage=None,
|
||||
stop_reason="stop",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
messages = FakeMessages()
|
||||
|
||||
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
|
||||
|
||||
async def run_case():
|
||||
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert captured_kwargs["max_tokens"] == 64_000
|
||||
@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||
from beaver.services.process_service import SessionProcessProjector
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
@ -109,6 +110,18 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="system",
|
||||
event_type="task_acceptance_recorded",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"acceptance_type": "accept",
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
@ -123,6 +136,232 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
assert any(event["actor_name"] == "Evidence" for event in projection["events"])
|
||||
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
||||
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["timeline_type"] == "plan"
|
||||
assert planned_event["metadata"]["plan_mode"] == "team"
|
||||
assert planned_event["metadata"]["strategy"] == "sequence"
|
||||
assert planned_event["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||
|
||||
skill_event = next(event for event in projection["events"] if event["kind"] == "skill_selected")
|
||||
assert skill_event["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_event["metadata"]["skill_names"] == ["research-workflow"]
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
assert "node_result" not in node_event["metadata"]
|
||||
|
||||
evidence_event = next(event for event in projection["events"] if event["kind"] == "task_result_ready")
|
||||
assert evidence_event["metadata"]["timeline_type"] == "result"
|
||||
assert evidence_event["status"] == "done"
|
||||
|
||||
acceptance_event = next(event for event in projection["events"] if event["kind"] == "task_acceptance_recorded")
|
||||
assert acceptance_event["metadata"]["timeline_type"] == "acceptance"
|
||||
|
||||
|
||||
def test_process_projection_maps_failed_task_team_events(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="failed-sub-run",
|
||||
session_id="failed-sub-session",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="failed sub task",
|
||||
started_at="2026-01-01T00:00:01+00:00",
|
||||
ended_at="2026-01-01T00:00:02+00:00",
|
||||
success=False,
|
||||
finish_reason="error",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_team_run_failed",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"team_success": False,
|
||||
"team_run_ids": ["failed-sub-run"],
|
||||
"error": "research node failed",
|
||||
"node_results": [
|
||||
{
|
||||
"node_id": "research",
|
||||
"success": False,
|
||||
"error": "source unavailable",
|
||||
"run_id": "failed-sub-run",
|
||||
"finish_reason": "error",
|
||||
}
|
||||
],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["status"] == "error"
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["status"] == "error"
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
|
||||
|
||||
def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": None,
|
||||
"strategy": None,
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
root_run = next(run for run in projection["runs"] if run["run_id"] == "task:task-1:attempt:1")
|
||||
assert root_run["metadata"]["plan_mode"] == "single"
|
||||
assert root_run["metadata"]["strategy"] == "single"
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["plan_mode"] == "single"
|
||||
assert planned_event["metadata"]["strategy"] == "single"
|
||||
|
||||
|
||||
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
activated_skills=[
|
||||
SkillActivationReceipt(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
skill_name="web-operation",
|
||||
skill_version="1",
|
||||
content_hash="hash",
|
||||
activated_at="2026-01-01T00:00:03+00:00",
|
||||
activation_reason="Needs live web lookup.",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": "single",
|
||||
"strategy": "single",
|
||||
"selected_skill_names": [],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_synthesis_completed",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
skill_events = [
|
||||
event
|
||||
for event in projection["events"]
|
||||
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
|
||||
]
|
||||
assert skill_events
|
||||
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
|
||||
|
||||
|
||||
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="assistant",
|
||||
event_type="assistant_message_added",
|
||||
event_payload={"task_id": "task-1"},
|
||||
content="Searching",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"name": "multi_search",
|
||||
"arguments": {"query": "Macau cafe near Bóvia"},
|
||||
}
|
||||
],
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="tool",
|
||||
event_type="tool_result_recorded",
|
||||
event_payload={"success": True, "error": None},
|
||||
content="Found 3 restaurants",
|
||||
tool_name="multi_search",
|
||||
tool_call_id="call-1",
|
||||
context_visible=True,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
|
||||
assert tool_call["metadata"]["timeline_type"] == "tool_call"
|
||||
assert tool_call["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_call["run_id"] == "main-run"
|
||||
|
||||
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
|
||||
assert tool_result["metadata"]["timeline_type"] == "tool_result"
|
||||
assert tool_result["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_result["metadata"]["success"] is True
|
||||
|
||||
|
||||
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
|
||||
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.tools.builtins import web
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
headers = {"content-type": "text/html"}
|
||||
status_code = 200
|
||||
text = '<a class="result__a" href="https://example.com">Example</a>'
|
||||
url = "https://example.com"
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
|
||||
_FakeAsyncClient.calls = []
|
||||
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
|
||||
async def _run() -> None:
|
||||
await web.WebFetchTool().execute(url="https://example.com")
|
||||
await web.WebSearchTool().execute(query="example")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]
|
||||
@ -15,7 +15,7 @@ import {
|
||||
Settings2,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { getStatus, updateProviderConfig } from '@/lib/api';
|
||||
import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -42,6 +42,12 @@ type ProviderFormState = {
|
||||
requestTimeoutSeconds: string;
|
||||
};
|
||||
|
||||
type AgentFormState = {
|
||||
maxTokens: string;
|
||||
temperature: string;
|
||||
maxToolIterations: string;
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
@ -57,6 +63,13 @@ export default function StatusPage() {
|
||||
}));
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
const [agentForm, setAgentForm] = useState<AgentFormState>(() => ({
|
||||
maxTokens: '',
|
||||
temperature: '0.2',
|
||||
maxToolIterations: '30',
|
||||
}));
|
||||
const [savingAgent, setSavingAgent] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
@ -64,6 +77,11 @@ export default function StatusPage() {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
setAgentForm({
|
||||
maxTokens: data.max_tokens == null ? '' : String(data.max_tokens),
|
||||
temperature: String(data.temperature),
|
||||
maxToolIterations: String(data.max_tool_iterations),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
|
||||
} finally {
|
||||
@ -115,6 +133,39 @@ export default function StatusPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAgentConfig = async () => {
|
||||
setSavingAgent(true);
|
||||
setAgentError(null);
|
||||
try {
|
||||
const maxTokensText = agentForm.maxTokens.trim();
|
||||
const maxTokens = maxTokensText ? Number(maxTokensText) : null;
|
||||
const temperature = Number(agentForm.temperature.trim());
|
||||
const maxToolIterations = Number(agentForm.maxToolIterations.trim());
|
||||
if (
|
||||
maxTokens !== null &&
|
||||
(!Number.isInteger(maxTokens) || maxTokens <= 0)
|
||||
) {
|
||||
throw new Error(pickAppText(locale, '最大令牌数必须为空或正整数', 'Max tokens must be blank or a positive integer'));
|
||||
}
|
||||
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
|
||||
throw new Error(pickAppText(locale, '温度必须在 0 到 2 之间', 'Temperature must be between 0 and 2'));
|
||||
}
|
||||
if (!Number.isInteger(maxToolIterations) || maxToolIterations < 0) {
|
||||
throw new Error(pickAppText(locale, '最大工具迭代次数必须是非负整数', 'Max tool iterations must be a non-negative integer'));
|
||||
}
|
||||
await updateAgentConfig({
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
});
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
setAgentError(err.message || pickAppText(locale, '保存智能体配置失败', 'Failed to save agent configuration'));
|
||||
} finally {
|
||||
setSavingAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@ -207,14 +258,47 @@ export default function StatusPage() {
|
||||
{pickAppText(locale, '智能体配置', 'Agent configuration')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-5">
|
||||
<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={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
value={String(status.max_tool_iterations)}
|
||||
/>
|
||||
<div className="grid gap-4 border-t pt-5 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tokens">{pickAppText(locale, '最大令牌数', 'Max tokens')}</Label>
|
||||
<Input
|
||||
id="agent-max-tokens"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxTokens}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxTokens: event.target.value }))}
|
||||
placeholder={pickAppText(locale, '模型默认', 'Model default')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-temperature">{pickAppText(locale, '温度', 'Temperature')}</Label>
|
||||
<Input
|
||||
id="agent-temperature"
|
||||
inputMode="decimal"
|
||||
value={agentForm.temperature}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, temperature: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tool-iterations">
|
||||
{pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-max-tool-iterations"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxToolIterations}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxToolIterations: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-destructive">{agentError || ''}</div>
|
||||
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
|
||||
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -3,127 +3,135 @@
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, Loader2, MessageSquare, RefreshCw, ThumbsUp, Trash2, User, XCircle } from 'lucide-react';
|
||||
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
TaskLiveHeader,
|
||||
TaskSideRail,
|
||||
TaskTimeline,
|
||||
type TaskFeedbackItem,
|
||||
type TaskFeedbackType,
|
||||
} from '@/components/task-detail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
type TaskFeedbackItem = {
|
||||
acceptance_type?: unknown;
|
||||
feedback_type?: unknown;
|
||||
comment?: unknown;
|
||||
created_at?: unknown;
|
||||
run_id?: unknown;
|
||||
};
|
||||
|
||||
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
|
||||
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
||||
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return task.stageLabel || task.status;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||
const TASK_RESULT_REVIEW_ID = 'task-result-review';
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
const processEvents = useChatStore((state) => state.processEvents);
|
||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||
const setSessionProcess = useChatStore((state) => state.setSessionProcess);
|
||||
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
||||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||
|
||||
const task = useMemo(
|
||||
() => buildTaskRuntimeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
);
|
||||
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
||||
const [backendTaskLoading, setBackendTaskLoading] = useState(true);
|
||||
const [revision, setRevision] = useState('');
|
||||
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const mountedRef = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedRunId(task?.rootRunId ?? null);
|
||||
setRuntimeFeedback(null);
|
||||
}, [task?.rootRunId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (task || !taskId) {
|
||||
setBackendTask(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setBackendTaskLoading(true);
|
||||
getBackendTask(taskId)
|
||||
.then((item) => {
|
||||
if (!cancelled) setBackendTask(item);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBackendTask(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBackendTaskLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [task, taskId]);
|
||||
}, []);
|
||||
|
||||
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
|
||||
const artifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
|
||||
[processArtifacts, runIds]
|
||||
const loadBackendTask = React.useCallback(async () => {
|
||||
if (!taskId) return null;
|
||||
setBackendTaskLoading(true);
|
||||
try {
|
||||
const item = await getBackendTask(taskId);
|
||||
if (!mountedRef.current) return item;
|
||||
setBackendTask(item);
|
||||
setSessionProcess(item.session_id, {
|
||||
runs: item.process_runs ?? [],
|
||||
events: item.process_events ?? [],
|
||||
artifacts: item.process_artifacts ?? [],
|
||||
});
|
||||
return item;
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setBackendTask(null);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBackendTaskLoading(false);
|
||||
}
|
||||
}
|
||||
}, [setSessionProcess, taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadBackendTask();
|
||||
}, [loadBackendTask]);
|
||||
|
||||
const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldPollTaskDetail(backendTask)) return;
|
||||
const id = window.setInterval(() => {
|
||||
void loadBackendTask();
|
||||
}, 4000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [backendTask, loadBackendTask]);
|
||||
|
||||
const taskRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
|
||||
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
|
||||
return ids;
|
||||
}, [backendTask]);
|
||||
|
||||
const liveRuns = useMemo(
|
||||
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
|
||||
[processRuns, taskId, taskRunIds]
|
||||
);
|
||||
const eventsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of processEvents) {
|
||||
if (!runIds.has(event.run_id)) continue;
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [processEvents, runIds]);
|
||||
const artifactsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
|
||||
}
|
||||
return map;
|
||||
}, [artifacts]);
|
||||
const phaseGroups = useMemo(() => {
|
||||
const groups = new Map<string, TaskRuntimeNodeView[]>();
|
||||
for (const item of task?.tasks ?? []) {
|
||||
const label = item.stageLabel || taskVisibleStatus(item, locale);
|
||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
|
||||
}, [locale, task?.tasks]);
|
||||
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
|
||||
|
||||
const liveEvents = useMemo(
|
||||
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||||
[processEvents, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const liveArtifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||||
[processArtifacts, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
|
||||
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
|
||||
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
|
||||
|
||||
const timelineCards = useMemo(
|
||||
() =>
|
||||
backendTask
|
||||
? buildTaskTimelineCards({
|
||||
task: backendTask,
|
||||
processRuns: renderedRuns,
|
||||
processEvents: renderedEvents,
|
||||
processArtifacts: renderedArtifacts,
|
||||
})
|
||||
: [],
|
||||
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
|
||||
);
|
||||
|
||||
const activeLabel =
|
||||
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||||
const durationMs = backendTask ? taskDetailDurationMs(backendTask) : null;
|
||||
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||
setActionBusy(key);
|
||||
@ -149,632 +157,97 @@ export default function TaskDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||
|
||||
if (!task && backendTask) {
|
||||
if (backendTask) {
|
||||
const feedbackItems = backendTask.feedback || [];
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
||||
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
|
||||
{backendTask.description ? (
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
|
||||
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
|
||||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)}</span>
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskFeedbackPanel
|
||||
sessionId={backendTask.session_id}
|
||||
runId={backendFeedbackRunId}
|
||||
taskStatus={backendTask.status}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
onSubmit={(feedbackType, comment) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
await submitChatFeedback({
|
||||
{actionError ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TaskTimeline
|
||||
cards={timelineCards}
|
||||
isLive={isTaskLive && wsStatus === 'connected'}
|
||||
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||
resultAcceptance={{
|
||||
sessionId: backendTask.session_id,
|
||||
runId: backendFeedbackRunId!,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
const refreshed = await getBackendTask(backendTask.task_id);
|
||||
setBackendTask(refreshed);
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<BackendExecutionStages task={backendTask} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{(backendTask.runs ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
|
||||
) : (
|
||||
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
runId: feedbackRunId,
|
||||
taskStatus: backendTask.status,
|
||||
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange: setRevision,
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||
await submitChatFeedback({
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||
setRevision('');
|
||||
await loadBackendTask();
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{backendTaskLoading
|
||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressValue = progressPercent(task.progress.value, task.progress.max);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
|
||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
|
||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)}</span>
|
||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
|
||||
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
|
||||
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
|
||||
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
|
||||
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.stats.alertCount)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{task.progress.label}</span>
|
||||
<span className="font-medium">{progressValue}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="flex justify-center">
|
||||
{backendTaskLoading ? <Loader2 className="mb-4 h-5 w-5 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{backendTaskLoading
|
||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||
: pickAppText(locale, '后端任务库里没有这个任务。', 'The backend task store does not contain this task.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{actionError && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phaseGroups.map((phase, index) => (
|
||||
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
|
||||
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
||||
<div className="font-medium">{phase.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
|
||||
</div>
|
||||
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{phaseGroups.map((phase) => (
|
||||
<Card key={phase.label}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{phase.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
{phase.nodes.map((node) => (
|
||||
<button
|
||||
key={node.runId}
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(node.runId)}
|
||||
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{node.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={node.status} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{node.summary || taskVisibleStatus(node, locale)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
|
||||
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="font-medium">{selectedNode.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={selectedNode.status} />
|
||||
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
|
||||
<div className="space-y-2">
|
||||
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
|
||||
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
|
||||
<div className="font-medium">{event.kind}</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || formatTaskRuntimeTime(event.created_at, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskFeedbackPanel
|
||||
sessionId={task.sessionId || 'web:default'}
|
||||
runId={task.rootRunId}
|
||||
taskStatus={task.status}
|
||||
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={setRevision}
|
||||
onSubmit={(feedbackType, comment) =>
|
||||
runAction(`runtime-feedback-${feedbackType}`, async () => {
|
||||
updateMessageFeedback(task.rootRunId, feedbackType);
|
||||
await submitChatFeedback({
|
||||
sessionId: task.sessionId || 'web:default',
|
||||
runId: task.rootRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
setRuntimeFeedback({
|
||||
acceptance_type: feedbackType,
|
||||
feedback_type: feedbackType,
|
||||
comment: comment || '',
|
||||
created_at: new Date().toISOString(),
|
||||
run_id: task.rootRunId,
|
||||
});
|
||||
setRevision('');
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={artifacts.length === 0}
|
||||
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '全部下载', 'Download all')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
||||
</div>
|
||||
{artifact.url || artifact.file_id ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendExecutionStages({ task }: { task: BackendTask }) {
|
||||
const { locale } = useAppI18n();
|
||||
const runs = task.process_runs ?? [];
|
||||
const events = task.process_events ?? [];
|
||||
const eventsByRun = React.useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of events) {
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
const metadata = run.metadata ?? {};
|
||||
const details = [
|
||||
metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null,
|
||||
metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null,
|
||||
metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null,
|
||||
metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null,
|
||||
metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null,
|
||||
].filter(Boolean);
|
||||
const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.actor_name}
|
||||
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
||||
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
||||
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
||||
{events.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{events.map((event) => (
|
||||
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{event.actor_name}</span>
|
||||
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFeedbackPanel({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy;
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{recordedFeedback ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? (
|
||||
<p className="mt-2 text-muted-foreground">{String(recordedFeedback.comment)}</p>
|
||||
) : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : isFinalized ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||||
</div>
|
||||
) : !runId ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<FeedbackButton
|
||||
type="accept"
|
||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '接受', 'Accept')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('accept', comment.trim() || undefined)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="revise"
|
||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit || !comment.trim()}
|
||||
onClick={() => submit('revise', comment.trim())}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="abandon"
|
||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('abandon', comment.trim() || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackButton({
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
actionBusy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
type: TaskFeedbackType;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
actionBusy: string | null;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isBusy = Boolean(actionBusy?.endsWith(type));
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
||||
const { locale } = useAppI18n();
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.started_at ? formatTaskRuntimeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
|
||||
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
|
||||
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{run.messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{run.messages.map((message, messageIndex) => {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isTool = message.role === 'tool';
|
||||
const Icon = isAssistant ? Bot : isTool ? FileText : User;
|
||||
return (
|
||||
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
|
||||
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
|
||||
{message.created_at ? <span>{formatTaskRuntimeTime(message.created_at, locale)}</span> : null}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||||
}
|
||||
|
||||
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
|
||||
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
|
||||
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return reason;
|
||||
}
|
||||
|
||||
function pickFeedbackRunId(task: BackendTask): string | null {
|
||||
const runIds = task.run_ids.filter(Boolean);
|
||||
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||||
@ -782,13 +255,3 @@ function pickFeedbackRunId(task: BackendTask): string | null {
|
||||
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||
if (!runId) return null;
|
||||
const ordered = [...items].reverse();
|
||||
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
||||
}
|
||||
|
||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||
return [...items].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
|
||||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
|
||||
export type TaskFeedbackItem = {
|
||||
acceptance_type?: unknown;
|
||||
feedback_type?: unknown;
|
||||
comment?: unknown;
|
||||
created_at?: unknown;
|
||||
run_id?: unknown;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||
if (!runId) return null;
|
||||
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
|
||||
}
|
||||
|
||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||
return [...items].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function feedbackKind(item: TaskFeedbackItem): string {
|
||||
return String(item.acceptance_type || item.feedback_type || '');
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function FeedbackButton({
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
actionBusy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
type: TaskFeedbackType;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
actionBusy: string | null;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceCard({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||
{isRuntimeStatus(taskStatus) ? (
|
||||
<TaskRuntimeStatusBadge status={taskStatus} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(taskStatus, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskAcceptanceControls
|
||||
sessionId={sessionId}
|
||||
runId={runId}
|
||||
taskStatus={taskStatus}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={onRevisionChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceControls({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{recordedFeedback ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : isFinalized ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||||
</div>
|
||||
) : !isReadyForAcceptance ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
|
||||
</div>
|
||||
) : !runId ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<FeedbackButton
|
||||
type="accept"
|
||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '接受', 'Accept')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('accept', trimmedComment || undefined)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="revise"
|
||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit || !trimmedComment}
|
||||
onClick={() => submit('revise', trimmedComment)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="abandon"
|
||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('abandon', trimmedComment || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal file
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
activeLabel: string;
|
||||
durationMs: number | null;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
|
||||
{task.description && task.description !== title ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
253
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
253
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, Bot, Download, ExternalLink, FileText, Users } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getFileUrl } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask, ProcessArtifact, ProcessRun, TaskTimelineCard } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
runs: ProcessRun[];
|
||||
artifacts: ProcessArtifact[];
|
||||
cards: TaskTimelineCard[];
|
||||
};
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set<ProcessRun['status']>(['queued', 'running', 'waiting']);
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['已接受', 'Accepted'],
|
||||
satisfied: ['已接受', 'Accepted'],
|
||||
revise: ['已请求修改', 'Revision requested'],
|
||||
abandon: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function latestFeedback(task: BackendTask): Record<string, unknown> | null {
|
||||
return [...(task.feedback ?? [])].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
|
||||
const feedback = latestFeedback(task);
|
||||
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
|
||||
if (kind) return humanTaskStatus(kind, locale);
|
||||
if (task.status === 'awaiting_acceptance') return pickAppText(locale, '等待验收', 'Awaiting acceptance');
|
||||
if (task.status === 'needs_revision') return pickAppText(locale, '等待修改', 'Awaiting revision');
|
||||
return pickAppText(locale, '未验收', 'No acceptance yet');
|
||||
}
|
||||
|
||||
function toTime(value: string): number {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isWarningOrError(card: TaskTimelineCard): boolean {
|
||||
const severity = String(card.details?.severity || card.details?.level || '').toLowerCase();
|
||||
return card.type === 'error' || card.status === 'error' || severity === 'warning' || severity === 'error';
|
||||
}
|
||||
|
||||
function artifactHref(artifact: ProcessArtifact): string | null {
|
||||
if (artifact.url) return artifact.url;
|
||||
if (artifact.file_id) return getFileUrl(artifact.file_id);
|
||||
return null;
|
||||
}
|
||||
|
||||
function inlineArtifactPayload(artifact: ProcessArtifact): { content: string; filename: string; mimeType: string } | null {
|
||||
const baseName = (artifact.title || artifact.artifact_id || 'artifact').replace(/[\\/:*?"<>|]+/g, '-');
|
||||
if (artifact.content !== undefined) {
|
||||
const isMarkdown = artifact.artifact_type === 'markdown';
|
||||
return {
|
||||
content: artifact.content,
|
||||
filename: `${baseName}.${isMarkdown ? 'md' : 'txt'}`,
|
||||
mimeType: isMarkdown ? 'text/markdown;charset=utf-8' : 'text/plain;charset=utf-8',
|
||||
};
|
||||
}
|
||||
if (artifact.data !== undefined) {
|
||||
return {
|
||||
content: JSON.stringify(artifact.data, null, 2),
|
||||
filename: `${baseName}.json`,
|
||||
mimeType: 'application/json;charset=utf-8',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadInlineArtifact(artifact: ProcessArtifact): void {
|
||||
const payload = inlineArtifactPayload(artifact);
|
||||
if (!payload) return;
|
||||
|
||||
const url = URL.createObjectURL(new Blob([payload.content], { type: payload.mimeType }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = payload.filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: ProcessRun }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{run.actor_name}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(run.started_at, locale)}</div>
|
||||
{run.summary ? <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{run.summary}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const activeRuns = runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status));
|
||||
const childRuns = runs.filter((run) => Boolean(run.parent_run_id));
|
||||
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '活跃运行', 'Active runs')}: <span className="font-medium text-foreground">{activeRuns.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收', 'Acceptance')}: {acceptanceState(task, locale)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '运行中', 'Active runs')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{activeRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无活跃运行', 'No active runs')}</p>
|
||||
) : (
|
||||
activeRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{latestAlert ? (
|
||||
<Card className="rounded-md border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
{pickAppText(locale, '最新提醒', 'Latest alert')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-sm font-medium">{latestAlert.title}</div>
|
||||
{latestAlert.summary ? <p className="text-sm text-muted-foreground">{latestAlert.summary}</p> : null}
|
||||
<div className="text-xs text-muted-foreground">{formatTaskRuntimeTime(latestAlert.createdAt, locale)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, 'Agent Team', 'Agent team')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{childRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无子运行', 'No child runs')}</p>
|
||||
) : (
|
||||
childRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '产物', 'Artifacts')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => {
|
||||
const href = artifactHref(artifact);
|
||||
const inlinePayload = inlineArtifactPayload(artifact);
|
||||
return (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||
</div>
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : inlinePayload ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||||
|
||||
import { TaskTimelineCard, type TaskResultAcceptance } from './TaskTimelineCard';
|
||||
|
||||
type Props = {
|
||||
cards: TaskTimelineCardView[];
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '实时更新', 'Live')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card className="rounded-md border-dashed">
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, 'Beaver 正在准备第一步。', 'Beaver is preparing the first step.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cards.map((card) => (
|
||||
<TaskTimelineCard key={card.id} card={card} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
History,
|
||||
ListChecks,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
ThumbsUp,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||
|
||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
|
||||
type Props = {
|
||||
card: TaskTimelineCardView;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export type TaskResultAcceptance = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function iconForType(type: TaskTimelineCardType) {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return ClipboardList;
|
||||
case 'plan':
|
||||
return ListChecks;
|
||||
case 'skill':
|
||||
return Sparkles;
|
||||
case 'tool_call':
|
||||
return Wrench;
|
||||
case 'tool_result':
|
||||
return TerminalSquare;
|
||||
case 'next_step':
|
||||
return ArrowRightCircle;
|
||||
case 'agent_team':
|
||||
return Users;
|
||||
case 'agent_progress':
|
||||
return Bot;
|
||||
case 'agent_handoff':
|
||||
return GitBranch;
|
||||
case 'artifact':
|
||||
return FileText;
|
||||
case 'error':
|
||||
return AlertTriangle;
|
||||
case 'result':
|
||||
return CheckCircle2;
|
||||
case 'result_history':
|
||||
return History;
|
||||
case 'acceptance':
|
||||
return ThumbsUp;
|
||||
}
|
||||
}
|
||||
|
||||
function detailsJson(details: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<TaskTimelineCardType, [string, string]> = {
|
||||
task_created: ['任务', 'Task'],
|
||||
plan: ['计划', 'Plan'],
|
||||
skill: ['Skill', 'Skill'],
|
||||
tool_call: ['工具调用', 'Tool call'],
|
||||
tool_result: ['工具结果', 'Tool result'],
|
||||
next_step: ['下一步', 'Next step'],
|
||||
agent_team: ['Agent Team', 'Agent team'],
|
||||
agent_progress: ['Agent', 'Agent'],
|
||||
agent_handoff: ['交接', 'Handoff'],
|
||||
artifact: ['产物', 'Artifact'],
|
||||
error: ['异常', 'Error'],
|
||||
result: ['结果', 'Result'],
|
||||
result_history: ['历史结果', 'Result history'],
|
||||
acceptance: ['验收', 'Acceptance'],
|
||||
};
|
||||
const label = labels[type];
|
||||
return pickAppText(locale, label[0], label[1]);
|
||||
}
|
||||
|
||||
function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
warning: ['提醒', 'Warning'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
|
||||
const versions = details?.versions;
|
||||
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
|
||||
}
|
||||
|
||||
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
|
||||
const status = String(version.acceptanceType || version.status || '');
|
||||
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
|
||||
}
|
||||
|
||||
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const versions = historyVersions(card.details);
|
||||
|
||||
return (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{versions.map((version, index) => (
|
||||
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{renderHistoryStatus(version, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
|
||||
{version.comment ? (
|
||||
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const Icon = iconForType(card.type);
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{card.title}</h3>
|
||||
<Badge variant="secondary" className="shrink-0 text-[11px]">
|
||||
{cardTypeLabel(card.type, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{card.actorName ? <span>{card.actorName}</span> : null}
|
||||
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{card.status ? (
|
||||
isRuntimeStatus(card.status) ? (
|
||||
<TaskRuntimeStatusBadge status={card.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 text-[11px]">
|
||||
{humanStatus(card.status, locale)}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
||||
|
||||
{shouldRenderResultAcceptance ? (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<TaskAcceptanceControls {...resultAcceptance!} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
|
||||
{detailsJson(card.details)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
app-instance/frontend/components/task-detail/index.ts
Normal file
5
app-instance/frontend/components/task-detail/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
export { TaskLiveHeader } from './TaskLiveHeader';
|
||||
export { TaskSideRail } from './TaskSideRail';
|
||||
export { TaskTimeline } from './TaskTimeline';
|
||||
export { TaskTimelineCard } from './TaskTimelineCard';
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
AuthzStatus,
|
||||
AuthUser,
|
||||
ActiveTask,
|
||||
AgentConfigPayload,
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
@ -620,6 +621,13 @@ export async function getStatus(): Promise<SystemStatus> {
|
||||
return fetchJSON('/api/status');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(payload: AgentConfigPayload): Promise<{ ok: boolean }> {
|
||||
return fetchJSON('/api/agent-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProviderConfig(
|
||||
providerId: string,
|
||||
payload: ProviderConfigPayload
|
||||
|
||||
@ -64,4 +64,29 @@ describe('chat store process event ingestion', () => {
|
||||
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('');
|
||||
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||
});
|
||||
|
||||
it('keeps live task events after persisted session projection is merged', () => {
|
||||
const store = useChatStore.getState();
|
||||
store.setSessionId('web:default');
|
||||
store.ingestProcessEvent({
|
||||
type: 'process_run_progress',
|
||||
session_id: 'web:default',
|
||||
run_id: 'run-live',
|
||||
parent_run_id: null,
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: '正在调用工具',
|
||||
metadata: { task_id: 'task-live', timeline_type: 'tool_call' },
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
});
|
||||
|
||||
store.setSessionProcess('web:default', {
|
||||
runs: [],
|
||||
events: [],
|
||||
artifacts: [],
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().processEvents.some((event) => event.run_id === 'run-live')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -117,6 +117,11 @@ function appendEvent(collection: ProcessEvent[], event: ProcessEvent): ProcessEv
|
||||
return [...collection, event];
|
||||
}
|
||||
|
||||
function hasTaskMetadata(item: { metadata?: Record<string, unknown> }): boolean {
|
||||
const taskId = item.metadata?.task_id;
|
||||
return typeof taskId === 'string' && taskId.trim().length > 0;
|
||||
}
|
||||
|
||||
function createEventId(event: ProcessWsEvent): string {
|
||||
if (event.type === 'process_cancel_ack') {
|
||||
return `${event.type}:${event.run_id}`;
|
||||
@ -393,7 +398,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const incomingArtifacts = projection.artifacts || [];
|
||||
const incomingRunIds = new Set(incomingRuns.map((run) => run.run_id));
|
||||
const nextRuns = [
|
||||
...state.processRuns.filter((run) => run.session_id !== sessionId && !incomingRunIds.has(run.run_id)),
|
||||
...state.processRuns.filter((run) => {
|
||||
if (incomingRunIds.has(run.run_id)) return false;
|
||||
if (run.session_id !== sessionId) return true;
|
||||
return hasTaskMetadata(run);
|
||||
}),
|
||||
...incomingRuns,
|
||||
];
|
||||
const liveRunIds = new Set(nextRuns.map((run) => run.run_id));
|
||||
|
||||
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const baseTask: BackendTask = {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:test',
|
||||
description: '查找餐厅',
|
||||
goal: '查找餐厅',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'main-agent',
|
||||
created_at: '2026-05-27T02:02:41.000Z',
|
||||
updated_at: '2026-05-27T02:02:41.500Z',
|
||||
run_ids: [],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
describe('task detail refresh helpers', () => {
|
||||
it('polls executing task details regardless of websocket status', () => {
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'running' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'open' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'awaiting_acceptance' })).toBe(false);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'closed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses current time for active task duration instead of stale updated_at', () => {
|
||||
vi.setSystemTime(new Date('2026-05-27T02:03:41.000Z'));
|
||||
|
||||
expect(taskDetailDurationMs(baseTask)).toBe(60_000);
|
||||
expect(taskDetailDurationMs({ ...baseTask, status: 'awaiting_acceptance', updated_at: '2026-05-27T02:10:55.000Z' })).toBe(494_000);
|
||||
});
|
||||
});
|
||||
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const EXECUTING_TASK_STATUSES = new Set(['open', 'queued', 'running']);
|
||||
const FINISHED_FOR_DURATION_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
export function shouldPollTaskDetail(task: Pick<BackendTask, 'status'> | null): boolean {
|
||||
return Boolean(task && EXECUTING_TASK_STATUSES.has(task.status));
|
||||
}
|
||||
|
||||
export function taskDetailDurationMs(task: Pick<BackendTask, 'created_at' | 'updated_at' | 'closed_at' | 'status'>): number | null {
|
||||
const start = new Date(task.created_at).getTime();
|
||||
const end = FINISHED_FOR_DURATION_STATUSES.has(task.status)
|
||||
? new Date(task.closed_at || task.updated_at).getTime()
|
||||
: Date.now();
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
469
app-instance/frontend/lib/task-timeline.test.ts
Normal file
469
app-instance/frontend/lib/task-timeline.test.ts
Normal file
@ -0,0 +1,469 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function makeTask(overrides: Partial<BackendTask> = {}): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Research the market',
|
||||
short_title: 'Market research',
|
||||
is_open: true,
|
||||
goal: 'Summarize the market',
|
||||
constraints: [],
|
||||
priority: 1,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
updated_at: '2026-05-26T10:00:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTaskTimelineCards', () => {
|
||||
it('builds ordered timeline cards from task process data', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Plan and coordinate',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:00:30.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'done',
|
||||
started_at: '2026-05-26T10:05:00.000Z',
|
||||
finished_at: '2026-05-26T10:05:30.000Z',
|
||||
summary: 'Finished reading source documents.',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-plan',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-skill',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'skill_selected',
|
||||
actor_type: 'system',
|
||||
actor_id: 'skill-router',
|
||||
actor_name: 'Skill Router',
|
||||
text: 'Research skill selected.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
selected_skill_names: ['research'],
|
||||
reason: 'Need source review.',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Documents read.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: '2 documents read successfully.',
|
||||
},
|
||||
},
|
||||
];
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-summary',
|
||||
run_id: 'run-research',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Research summary',
|
||||
artifact_type: 'markdown',
|
||||
content: '# Summary',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
});
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'agent_progress',
|
||||
'artifact',
|
||||
]);
|
||||
expect(cards[1].title).toBe('执行计划');
|
||||
expect(cards[2].title).toBe('选择 Skill');
|
||||
expect(cards[4].summary).toBe('2 documents read successfully.');
|
||||
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
|
||||
});
|
||||
|
||||
it('appends result and acceptance cards for closed tasks with feedback', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cards = buildTaskTimelineCards({ task });
|
||||
|
||||
expect(cards.at(-2)?.type).toBe('result');
|
||||
expect(cards.at(-1)?.type).toBe('acceptance');
|
||||
expect(cards.at(-1)?.summary).toContain('可以');
|
||||
});
|
||||
|
||||
it('uses the latest assistant message from the acceptance run as the result body', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Draft answer', created_at: '2026-05-26T10:03:00.000Z' },
|
||||
{ role: 'assistant', content: 'Final user-visible answer', created_at: '2026-05-26T10:04:00.000Z' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'The task result is ready for user acceptance.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: 'Summary should not replace the final answer.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
const result = cards.find((card) => card.type === 'result');
|
||||
|
||||
expect(result?.summary).toBe('Final user-visible answer');
|
||||
expect(result?.details?.result_summary).toBe('Summary should not replace the final answer.');
|
||||
});
|
||||
|
||||
it('collapses previous result and acceptance cards into a history pack', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:12:00.000Z',
|
||||
run_ids: ['run-1', 'run-2'],
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'revise',
|
||||
comment: 'Add decisions',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
run_id: 'run-1',
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-1',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version one answer', created_at: '2026-05-26T10:05:00.000Z' }],
|
||||
},
|
||||
{
|
||||
run_id: 'run-2',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version two answer', created_at: '2026-05-26T10:12:00.000Z' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-1',
|
||||
run_id: 'run-1',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result one ready.',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-plan-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Task Planner',
|
||||
text: 'Second attempt planned.',
|
||||
created_at: '2026-05-26T10:08:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-result-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result two ready.',
|
||||
created_at: '2026-05-26T10:12:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'result_history',
|
||||
'plan',
|
||||
'result',
|
||||
]);
|
||||
const history = cards.find((card) => card.type === 'result_history');
|
||||
expect(history?.summary).toBe('1 历史结果版本');
|
||||
expect(history?.details?.versions).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
result: 'Version one answer',
|
||||
acceptanceType: 'revise',
|
||||
comment: 'Add decisions',
|
||||
}),
|
||||
]);
|
||||
expect(cards.find((card) => card.id === 'evt-plan-2')).toBeTruthy();
|
||||
expect(cards.at(-1)?.summary).toBe('Version two answer');
|
||||
});
|
||||
|
||||
it('does not add fallback progress when a child run already has progress events', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-progress',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processRuns, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.runId === 'run-research' && card.type === 'agent_progress')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress');
|
||||
});
|
||||
|
||||
it('marks a tool call as finished when a matching tool result exists', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Calling tool: web_search.',
|
||||
status: 'running',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Search failed.',
|
||||
status: 'error',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
result_summary: 'Search failed.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-tool-start')?.status).toBe('error');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.type).toBe('tool_result');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.summary).toBe('Search failed.');
|
||||
});
|
||||
|
||||
it('maps agent_finished events without timeline metadata to agent progress cards', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-agent-finished',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'agent_finished',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Finished reading source documents.',
|
||||
status: 'done',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-agent-finished')?.type).toBe('agent_progress');
|
||||
});
|
||||
|
||||
it('sorts invalid timestamps after valid timestamps while preserving insertion order', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-invalid-date',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: 'not-a-date',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.id)).toEqual(['task-1:created', 'evt-invalid-date']);
|
||||
});
|
||||
|
||||
it('dedupes synthetic result and acceptance milestones when lifecycle events exist', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Result is ready.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-acceptance-recorded',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_acceptance_recorded',
|
||||
actor_type: 'user',
|
||||
actor_id: 'user-acceptance',
|
||||
actor_name: 'User Acceptance',
|
||||
text: '可以',
|
||||
created_at: '2026-05-26T10:05:02.000Z',
|
||||
metadata: {
|
||||
acceptance_type: 'accept',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.type === 'result')).toHaveLength(1);
|
||||
expect(cards.filter((card) => card.type === 'acceptance')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).toContain('evt-result-ready');
|
||||
expect(cards.map((card) => card.id)).toContain('evt-acceptance-recorded');
|
||||
});
|
||||
});
|
||||
490
app-instance/frontend/lib/task-timeline.ts
Normal file
490
app-instance/frontend/lib/task-timeline.ts
Normal file
@ -0,0 +1,490 @@
|
||||
import type {
|
||||
BackendTask,
|
||||
ProcessArtifact,
|
||||
ProcessEvent,
|
||||
ProcessRun,
|
||||
TaskTimelineCard,
|
||||
TaskTimelineCardType,
|
||||
} from '@/types';
|
||||
|
||||
export type BuildTaskTimelineCardsInput = {
|
||||
task: BackendTask;
|
||||
processRuns?: ProcessRun[];
|
||||
processEvents?: ProcessEvent[];
|
||||
processArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'next_step',
|
||||
'agent_team',
|
||||
'agent_progress',
|
||||
'agent_handoff',
|
||||
'artifact',
|
||||
'error',
|
||||
'result',
|
||||
'result_history',
|
||||
'acceptance',
|
||||
]);
|
||||
|
||||
const RESULT_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
function isTimelineCardType(value: unknown): value is TaskTimelineCardType {
|
||||
return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType);
|
||||
}
|
||||
|
||||
function toTime(value: string): number | null {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeSkillNames(metadata: Record<string, unknown> | undefined): string[] | undefined {
|
||||
if (!metadata || (!('skill_names' in metadata) && !('selected_skill_names' in metadata))) {
|
||||
return undefined;
|
||||
}
|
||||
const names = [
|
||||
...stringList(metadata.skill_names),
|
||||
...stringList(metadata.selected_skill_names),
|
||||
];
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
|
||||
function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
const timelineType = event.metadata?.timeline_type;
|
||||
if (isTimelineCardType(timelineType)) {
|
||||
return timelineType;
|
||||
}
|
||||
|
||||
switch (String(event.kind)) {
|
||||
case 'task_planned':
|
||||
case 'run_started':
|
||||
return 'plan';
|
||||
case 'skill_selected':
|
||||
return 'skill';
|
||||
case 'tool_call_started':
|
||||
return 'tool_call';
|
||||
case 'tool_call_finished':
|
||||
return 'tool_result';
|
||||
case 'agent_team_created':
|
||||
return 'agent_team';
|
||||
case 'agent_handoff':
|
||||
return 'agent_handoff';
|
||||
case 'agent_finished':
|
||||
case 'run_progress':
|
||||
case 'run_finished':
|
||||
return 'agent_progress';
|
||||
case 'task_result_ready':
|
||||
return 'result';
|
||||
case 'task_acceptance_recorded':
|
||||
return 'acceptance';
|
||||
case 'task_error':
|
||||
return 'error';
|
||||
default:
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return '任务已创建';
|
||||
case 'plan':
|
||||
return '执行计划';
|
||||
case 'skill':
|
||||
return '选择 Skill';
|
||||
case 'tool_call':
|
||||
return actorName ? `调用工具:${actorName}` : '调用工具';
|
||||
case 'tool_result':
|
||||
return actorName ? `工具结果:${actorName}` : '工具结果';
|
||||
case 'next_step':
|
||||
return '下一步';
|
||||
case 'agent_team':
|
||||
return '启动 Agent Team';
|
||||
case 'agent_progress':
|
||||
return actorName || 'Agent 进展';
|
||||
case 'agent_handoff':
|
||||
return 'Agent 交接';
|
||||
case 'artifact':
|
||||
return '生成产物';
|
||||
case 'error':
|
||||
return '执行遇到问题';
|
||||
case 'result':
|
||||
return '本轮结果';
|
||||
case 'result_history':
|
||||
return '历史结果版本';
|
||||
case 'acceptance':
|
||||
return '任务验收';
|
||||
}
|
||||
}
|
||||
|
||||
function summaryForEvent(event: ProcessEvent): string | undefined {
|
||||
return firstString(
|
||||
event.metadata?.result_summary,
|
||||
event.metadata?.reason,
|
||||
event.metadata?.action_summary,
|
||||
event.text,
|
||||
);
|
||||
}
|
||||
|
||||
function detailsForEvent(event: ProcessEvent): Record<string, unknown> | undefined {
|
||||
const skillNames = normalizeSkillNames(event.metadata);
|
||||
if (!event.metadata && !skillNames) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(event.metadata ?? {}),
|
||||
...(skillNames ? { skill_names: skillNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackCreatedAt(feedback: Record<string, unknown>, task: BackendTask): string {
|
||||
return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at;
|
||||
}
|
||||
|
||||
function feedbackSummary(feedback: Record<string, unknown>): string | undefined {
|
||||
return firstString(feedback.comment, feedback.summary, feedback.acceptance_type);
|
||||
}
|
||||
|
||||
function acceptanceTypeFromRecord(record: Record<string, unknown> | undefined): string | null {
|
||||
return firstString(record?.acceptance_type, record?.feedback_type)?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function resultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(
|
||||
task.metadata?.result_summary,
|
||||
task.metadata?.summary,
|
||||
task.close_reason,
|
||||
task.validation_result?.summary,
|
||||
);
|
||||
}
|
||||
|
||||
function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined {
|
||||
if (!runId) return undefined;
|
||||
const run = (task.runs ?? []).find((item) => item.run_id === runId);
|
||||
if (!run) return undefined;
|
||||
const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim());
|
||||
return lastItem(assistantMessages)?.content.trim();
|
||||
}
|
||||
|
||||
function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined {
|
||||
return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event));
|
||||
}
|
||||
|
||||
function fallbackResultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task));
|
||||
}
|
||||
|
||||
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
|
||||
const map = new Map<string, ProcessRun>();
|
||||
for (const run of processRuns) {
|
||||
map.set(run.run_id, run);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function lastItem<T>(items: T[]): T | null {
|
||||
return items.length > 0 ? items[items.length - 1] : null;
|
||||
}
|
||||
|
||||
function compareCardsByCreatedAt(
|
||||
a: { card: TaskTimelineCard; index: number },
|
||||
b: { card: TaskTimelineCard; index: number },
|
||||
): number {
|
||||
const aTime = toTime(a.card.createdAt);
|
||||
const bTime = toTime(b.card.createdAt);
|
||||
|
||||
if (aTime === null && bTime === null) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
if (aTime === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bTime === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aTime - bTime || a.index - b.index;
|
||||
}
|
||||
|
||||
type AcceptanceEventIdentity = {
|
||||
runId: string | null;
|
||||
acceptanceType: string | null;
|
||||
};
|
||||
|
||||
function isCoveredByAcceptanceEvent(
|
||||
feedback: Record<string, unknown>,
|
||||
acceptanceEvents: AcceptanceEventIdentity[],
|
||||
): boolean {
|
||||
const feedbackType = acceptanceTypeFromRecord(feedback);
|
||||
if (!feedbackType) return false;
|
||||
|
||||
const feedbackRunId = firstString(feedback.run_id) ?? null;
|
||||
const matchingTypeEvents = acceptanceEvents.filter((event) => event.acceptanceType === feedbackType);
|
||||
|
||||
if (feedbackRunId) {
|
||||
return (
|
||||
matchingTypeEvents.some((event) => event.runId === feedbackRunId) ||
|
||||
(matchingTypeEvents.length === 1 && !matchingTypeEvents[0].runId)
|
||||
);
|
||||
}
|
||||
|
||||
return matchingTypeEvents.length === 1;
|
||||
}
|
||||
|
||||
function cardTime(card: TaskTimelineCard): number {
|
||||
return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function cardComment(card: TaskTimelineCard): string | undefined {
|
||||
return firstString(card.details?.comment, card.summary);
|
||||
}
|
||||
|
||||
function toolCallKeyFromEvent(event: ProcessEvent): string | null {
|
||||
const toolCallId = firstString(event.metadata?.tool_call_id);
|
||||
if (toolCallId) return `${event.run_id}:${toolCallId}`;
|
||||
|
||||
const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id);
|
||||
if (toolName) return `${event.run_id}:${toolName}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string, string> {
|
||||
const statuses = new Map<string, string>();
|
||||
for (const event of processEvents) {
|
||||
if (cardTypeForEvent(event) !== 'tool_result') continue;
|
||||
const key = toolCallKeyFromEvent(event);
|
||||
if (!key) continue;
|
||||
statuses.set(key, event.status || 'done');
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
|
||||
const versions = resultCards.map((resultCard) => {
|
||||
const acceptanceCard = acceptanceCards
|
||||
.filter((card) => card.runId === resultCard.runId)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b))
|
||||
.at(-1);
|
||||
return {
|
||||
runId: resultCard.runId ?? null,
|
||||
result: resultCard.summary ?? '',
|
||||
createdAt: resultCard.createdAt,
|
||||
status: acceptanceCard?.status ?? resultCard.status ?? null,
|
||||
acceptanceType: acceptanceCard?.status ?? null,
|
||||
comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '',
|
||||
acceptedAt: acceptanceCard?.createdAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${task.task_id}:result-history`,
|
||||
taskId: task.task_id,
|
||||
type: 'result_history',
|
||||
title: titleForCard('result_history'),
|
||||
summary: `${resultCards.length} 历史结果版本`,
|
||||
createdAt: resultCards[0]?.createdAt ?? task.created_at,
|
||||
details: { versions },
|
||||
};
|
||||
}
|
||||
|
||||
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
|
||||
const resultCards = cards.filter((card) => card.type === 'result');
|
||||
if (resultCards.length <= 1) return cards;
|
||||
|
||||
const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id);
|
||||
const latestResult =
|
||||
(finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ??
|
||||
[...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1);
|
||||
if (!latestResult) return cards;
|
||||
|
||||
const oldResults = resultCards
|
||||
.filter((card) => card.id !== latestResult.id)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
if (oldResults.length === 0) return cards;
|
||||
|
||||
const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean));
|
||||
const oldAcceptances = cards
|
||||
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
|
||||
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
|
||||
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
|
||||
const output: TaskTimelineCard[] = [];
|
||||
|
||||
for (let index = 0; index < cards.length; index += 1) {
|
||||
if (index === firstOldResultIndex) {
|
||||
output.push(historyCard);
|
||||
}
|
||||
if (!foldedIds.has(cards[index].id)) {
|
||||
output.push(cards[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
|
||||
const { task } = input;
|
||||
const processRuns = input.processRuns ?? task.process_runs ?? [];
|
||||
const processEvents = input.processEvents ?? task.process_events ?? [];
|
||||
const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? [];
|
||||
const runsById = buildRunMap(processRuns);
|
||||
const toolResultStatusByCall = buildToolResultStatusByCall(processEvents);
|
||||
const runsWithProgressEvents = new Set<string>();
|
||||
const acceptanceEvents: AcceptanceEventIdentity[] = [];
|
||||
let hasResultEventCard = false;
|
||||
const cards: TaskTimelineCard[] = [
|
||||
{
|
||||
id: `${task.task_id}:created`,
|
||||
taskId: task.task_id,
|
||||
type: 'task_created',
|
||||
title: titleForCard('task_created'),
|
||||
summary: firstString(task.short_title, task.description, task.goal),
|
||||
actorName: task.creator,
|
||||
status: task.status,
|
||||
createdAt: task.created_at,
|
||||
details: task.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of processEvents) {
|
||||
const type = cardTypeForEvent(event);
|
||||
if (!type) continue;
|
||||
if (type === 'agent_progress') {
|
||||
runsWithProgressEvents.add(event.run_id);
|
||||
}
|
||||
if (type === 'result') {
|
||||
hasResultEventCard = true;
|
||||
}
|
||||
if (type === 'acceptance') {
|
||||
acceptanceEvents.push({
|
||||
runId: firstString(event.run_id) ?? null,
|
||||
acceptanceType: acceptanceTypeFromRecord(event.metadata),
|
||||
});
|
||||
}
|
||||
|
||||
cards.push({
|
||||
id: event.event_id,
|
||||
taskId: task.task_id,
|
||||
runId: event.run_id,
|
||||
parentRunId: event.parent_run_id,
|
||||
type,
|
||||
title: titleForCard(type, event.actor_name),
|
||||
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
|
||||
actorName: event.actor_name,
|
||||
status:
|
||||
type === 'tool_call'
|
||||
? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status
|
||||
: event.status,
|
||||
createdAt: event.created_at,
|
||||
details: detailsForEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
if (runsWithProgressEvents.has(run.run_id)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${run.run_id}:fallback-progress`,
|
||||
taskId: task.task_id,
|
||||
runId: run.run_id,
|
||||
parentRunId: run.parent_run_id,
|
||||
type: 'agent_progress',
|
||||
title: titleForCard('agent_progress', run.actor_name),
|
||||
summary: firstString(run.summary, run.title),
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
createdAt: run.started_at,
|
||||
details: run.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const artifact of processArtifacts) {
|
||||
const run = runsById.get(artifact.run_id);
|
||||
cards.push({
|
||||
id: artifact.artifact_id,
|
||||
taskId: task.task_id,
|
||||
runId: artifact.run_id,
|
||||
parentRunId: run?.parent_run_id,
|
||||
type: 'artifact',
|
||||
title: titleForCard('artifact'),
|
||||
summary: firstString(artifact.title),
|
||||
actorName: artifact.actor_name,
|
||||
createdAt: artifact.created_at,
|
||||
relatedArtifactIds: [artifact.artifact_id],
|
||||
details: {
|
||||
...(artifact.metadata ?? {}),
|
||||
artifact_type: artifact.artifact_type,
|
||||
title: artifact.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (RESULT_STATUSES.has(task.status) && !hasResultEventCard) {
|
||||
cards.push({
|
||||
id: `${task.task_id}:result`,
|
||||
taskId: task.task_id,
|
||||
runId: lastItem(task.run_ids),
|
||||
type: 'result',
|
||||
title: titleForCard('result'),
|
||||
summary: fallbackResultSummary(task),
|
||||
status: task.status,
|
||||
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
|
||||
details: task.validation_result ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < task.feedback.length; index += 1) {
|
||||
const feedback = task.feedback[index];
|
||||
const runId = firstString(feedback.run_id) ?? null;
|
||||
const createdAt = feedbackCreatedAt(feedback, task);
|
||||
if (isCoveredByAcceptanceEvent(feedback, acceptanceEvents)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${task.task_id}:acceptance:${index}`,
|
||||
taskId: task.task_id,
|
||||
runId,
|
||||
type: 'acceptance',
|
||||
title: titleForCard('acceptance'),
|
||||
summary: feedbackSummary(feedback),
|
||||
status: firstString(feedback.acceptance_type),
|
||||
createdAt,
|
||||
details: feedback,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedCards = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.sort(compareCardsByCreatedAt)
|
||||
.map(({ card }) => card);
|
||||
|
||||
return collapseHistoricalResults(task, sortedCards);
|
||||
}
|
||||
@ -142,6 +142,12 @@ export interface ProviderConfigPayload {
|
||||
request_timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface AgentConfigPayload {
|
||||
max_tokens: number | null;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
@ -153,7 +159,7 @@ export interface SystemStatus {
|
||||
workspace: string;
|
||||
workspace_exists: boolean;
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
max_tokens: number | null;
|
||||
max_context_messages?: number;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
@ -434,7 +440,7 @@ export interface SkillHubInstallResponse {
|
||||
already_installed?: boolean;
|
||||
}
|
||||
|
||||
export type ProcessActorType = 'agent' | 'mcp' | 'system';
|
||||
export type ProcessActorType = 'agent' | 'mcp' | 'system' | 'user';
|
||||
export type ProcessRunStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
@ -449,7 +455,17 @@ export type ProcessEventKind =
|
||||
| 'run_artifact'
|
||||
| 'run_status'
|
||||
| 'run_finished'
|
||||
| 'run_cancelled';
|
||||
| 'run_cancelled'
|
||||
| 'task_planned'
|
||||
| 'skill_selected'
|
||||
| 'tool_call_started'
|
||||
| 'tool_call_finished'
|
||||
| 'agent_team_created'
|
||||
| 'agent_finished'
|
||||
| 'agent_handoff'
|
||||
| 'task_result_ready'
|
||||
| 'task_acceptance_recorded'
|
||||
| 'task_error';
|
||||
|
||||
export interface UiAgentDescriptor {
|
||||
id: string;
|
||||
@ -771,6 +787,37 @@ export interface ProcessArtifact {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type TaskTimelineCardType =
|
||||
| 'task_created'
|
||||
| 'plan'
|
||||
| 'skill'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'next_step'
|
||||
| 'agent_team'
|
||||
| 'agent_progress'
|
||||
| 'agent_handoff'
|
||||
| 'artifact'
|
||||
| 'error'
|
||||
| 'result'
|
||||
| 'result_history'
|
||||
| 'acceptance';
|
||||
|
||||
export interface TaskTimelineCard {
|
||||
id: string;
|
||||
taskId: string;
|
||||
runId?: string | null;
|
||||
parentRunId?: string | null;
|
||||
type: TaskTimelineCardType;
|
||||
title: string;
|
||||
summary?: string;
|
||||
actorName?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
relatedArtifactIds?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionProcessProjection {
|
||||
runs: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
|
||||
61
scripts/deploy-initial-skills.sh
Normal file
61
scripts/deploy-initial-skills.sh
Normal file
@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Deploy initial skills to all runtime instances via docker cp
|
||||
# Usage: ./scripts/deploy-initial-skills.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_SOURCE="/home/ivan/xuan/beaver_project/skills"
|
||||
DOCKER_NAMES=("app-instance-steven" "app-instance-benson" "app-instance-jayc" "app-instance-officebench")
|
||||
|
||||
SKILLS=(
|
||||
"outlook-mail"
|
||||
"filesystem-operation"
|
||||
"terminal-operation"
|
||||
"web-operation"
|
||||
"utility-tools"
|
||||
"skills-admin"
|
||||
"cron-scheduler"
|
||||
"memory-management"
|
||||
)
|
||||
|
||||
for container in "${DOCKER_NAMES[@]}"; do
|
||||
echo "==> Deploying to $container..."
|
||||
|
||||
docker exec "$container" mkdir -p /root/.beaver/workspace/skills/_index
|
||||
|
||||
for skill in "${SKILLS[@]}"; do
|
||||
if [ -d "$SKILL_SOURCE/$skill" ]; then
|
||||
docker cp "$SKILL_SOURCE/$skill" "$container":/root/.beaver/workspace/skills/
|
||||
echo " + $skill"
|
||||
fi
|
||||
done
|
||||
|
||||
# Merge index: keep existing entries + add new skills, no duplicates
|
||||
docker exec "$container" python3 -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
idx = Path('/root/.beaver/workspace/skills/_index/published.json')
|
||||
existing = json.loads(idx.read_text()) if idx.exists() else {'items': []}
|
||||
|
||||
new_skills = $(printf '["%s"]' "$(IFS=,; echo "${SKILLS[*]}")" | sed 's/,/", "/g')
|
||||
|
||||
seen = set(existing['items'])
|
||||
for s in new_skills:
|
||||
if s not in seen:
|
||||
existing['items'].append(s)
|
||||
seen.add(s)
|
||||
|
||||
idx.write_text(json.dumps(existing, ensure_ascii=False, indent=2) + '\n')
|
||||
print(f\"Index updated: {len(existing['items'])} skills\")
|
||||
"
|
||||
|
||||
docker cp "$SKILL_SOURCE/_index/disabled.json" "$container":/root/.beaver/workspace/skills/_index/disabled.json
|
||||
|
||||
echo " [done]"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done! All skills deployed to all instances."
|
||||
echo "Containers: ${DOCKER_NAMES[*]}"
|
||||
echo "Skills: ${SKILLS[*]}"
|
||||
3
skills/_index/disabled.json
Normal file
3
skills/_index/disabled.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
13
skills/_index/published.json
Normal file
13
skills/_index/published.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"items": [
|
||||
"outlook-mail",
|
||||
"filesystem-operation",
|
||||
"terminal-operation",
|
||||
"web-operation",
|
||||
"utility-tools",
|
||||
"skills-admin",
|
||||
"cron-scheduler",
|
||||
"memory-management",
|
||||
"officebench-mcp"
|
||||
]
|
||||
}
|
||||
3
skills/cron-scheduler/current.json
Normal file
3
skills/cron-scheduler/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/cron-scheduler/skill.json
Normal file
13
skills/cron-scheduler/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "定时任务和周期性调度。支持标准 cron 表达式、一次性提醒和持久化任务。",
|
||||
"display_name": "cron-scheduler",
|
||||
"lineage": [],
|
||||
"name": "cron-scheduler",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["cron", "scheduler", "timer", "periodic"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
34
skills/cron-scheduler/versions/v0001/SKILL.md
Normal file
34
skills/cron-scheduler/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
name: cron-scheduler
|
||||
description: 定时任务和周期性调度。支持标准 cron 表达式、一次性提醒和持久化任务。
|
||||
tools:
|
||||
- cron
|
||||
---
|
||||
|
||||
# Cron Scheduler — 定时任务调度
|
||||
|
||||
基于 cron 表达式的定时任务和一次性提醒。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### cron
|
||||
创建和管理 Beaver 定时通知或 Task。
|
||||
- `action` (str): `add` | `list` | `remove` | `toggle` | `run`
|
||||
- `message` (str): 触发时执行的任务说明,`add` 时必填
|
||||
- `schedule` (str): 调度表达式,例如 `every 15m`、`0 9 * * *` 或 ISO 时间
|
||||
- `every_seconds` (int | None): 固定秒级间隔
|
||||
- `cron_expr` (str | None): 标准 5 段 cron 表达式
|
||||
- `tz` (str | None): IANA 时区,例如 `Asia/Shanghai`
|
||||
- `at_iso` (str | None): 一次性任务的 ISO 时间
|
||||
- `job_id` (str | None): `remove`、`toggle`、`run` 目标任务 ID
|
||||
- `enabled` (bool | None): `toggle` 时设置启停状态
|
||||
- `mode` (str | None): `notification` 或 `task`
|
||||
- `requires_followup` (bool | None): task 模式下是否需要用户跟进
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 避开 :00 和 :30 整点分钟,分散负载
|
||||
2. 一次性提醒优先使用 `at_iso` 或清晰的 `schedule`
|
||||
3. 需要持续提醒时使用 `mode="notification"`,需要 Task 跟踪时才用 `mode="task"`
|
||||
4. 定期用 `action="list"` 确认任务是否按预期调度
|
||||
5. 任务触发时 `message` 会完整执行,确保内容自包含
|
||||
22
skills/cron-scheduler/versions/v0001/version.json
Normal file
22
skills/cron-scheduler/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for cron scheduling",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "定时任务和周期性调度。支持标准 cron 表达式、一次性提醒和持久化任务。",
|
||||
"name": "cron-scheduler",
|
||||
"tools": ["cron"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "cron-scheduler",
|
||||
"summary": "Cron Scheduler — 基于 cron 表达式的定时任务和一次性提醒",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["cron"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/filesystem-operation/current.json
Normal file
3
skills/filesystem-operation/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/filesystem-operation/skill.json
Normal file
13
skills/filesystem-operation/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "本地文件系统读写、搜索和目录操作。支持读取、写入、修改、搜索文件和目录遍历。",
|
||||
"display_name": "filesystem-operation",
|
||||
"lineage": [],
|
||||
"name": "filesystem-operation",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["filesystem", "file", "io", "directory"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
50
skills/filesystem-operation/versions/v0001/SKILL.md
Normal file
50
skills/filesystem-operation/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
name: filesystem-operation
|
||||
description: 本地文件系统读写、搜索和目录操作。支持读取、写入、修改、搜索文件和目录遍历。
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- patch_file
|
||||
- search_files
|
||||
- list_directory
|
||||
---
|
||||
|
||||
# Filesystem Operation — 文件系统操作
|
||||
|
||||
本地文件系统工具集,用于读写和搜索项目文件。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### read_file
|
||||
读取本地文件内容。
|
||||
- 使用 `skill_view` 查看文件预览
|
||||
- 大文件会分页返回,可通过 offset/limit 控制
|
||||
|
||||
### write_file
|
||||
写入新文件或覆盖已有文件。
|
||||
- 创建新文件时自动创建父目录
|
||||
- 写入前确认不会覆盖重要配置
|
||||
|
||||
### patch_file
|
||||
精确修改文件中的指定内容。
|
||||
- 通过搜索-替换方式修改
|
||||
- 适用于局部更新,避免整文件重写
|
||||
|
||||
### search_files
|
||||
在项目中搜索文件名或内容。
|
||||
- 支持 glob 模式匹配
|
||||
- 支持按内容搜索
|
||||
- 支持限制搜索目录深度
|
||||
|
||||
### list_directory
|
||||
列出目录内容。
|
||||
- 可递归列出子目录
|
||||
- 支持过滤文件类型
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 优先使用 `read_file` 查看文件内容,再决定修改方案
|
||||
2. 小范围修改用 `patch_file`,大范围用 `write_file`
|
||||
3. 搜索文件时先确认路径是否存在
|
||||
4. 修改前确认文件编码(默认 UTF-8)
|
||||
5. 敏感文件(.env、密钥等)不写入版本控制
|
||||
22
skills/filesystem-operation/versions/v0001/version.json
Normal file
22
skills/filesystem-operation/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for local filesystem operations",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "本地文件系统读写、搜索和目录操作。支持读取、写入、修改、搜索文件和目录遍历。",
|
||||
"name": "filesystem-operation",
|
||||
"tools": ["read_file", "write_file", "patch_file", "search_files", "list_directory"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "filesystem-operation",
|
||||
"summary": "Filesystem Operation — 本地文件系统操作工具集",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/memory-management/current.json
Normal file
3
skills/memory-management/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/memory-management/skill.json
Normal file
13
skills/memory-management/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "持久化记忆管理。存储用户信息、项目上下文、偏好和反馈,实现跨会话记忆。",
|
||||
"display_name": "memory-management",
|
||||
"lineage": [],
|
||||
"name": "memory-management",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["memory", "persistence", "context", "preferences"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
32
skills/memory-management/versions/v0001/SKILL.md
Normal file
32
skills/memory-management/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
name: memory-management
|
||||
description: 持久化记忆管理。存储用户信息、项目上下文、偏好和反馈,实现跨会话记忆。
|
||||
tools:
|
||||
- memory
|
||||
---
|
||||
|
||||
# Memory Management — 记忆管理
|
||||
|
||||
持久化记忆系统,保存用户角色、项目上下文、偏好反馈等跨会话信息。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### memory
|
||||
管理记忆条目(增删改查)。
|
||||
- `action` (str): `add` | `replace` | `remove`
|
||||
- `target` (str): `user` 或 `memory`
|
||||
- `content` (str | None): `add` 和 `replace` 时的新内容
|
||||
- `old_text` (str | None): `replace` 和 `remove` 时定位旧条目的唯一短文本
|
||||
- 记忆目标:
|
||||
- `user`: 用户角色、职责、知识背景、稳定偏好
|
||||
- `memory`: 项目约定、环境事实、稳定工具经验
|
||||
- 支持自动保存和检索
|
||||
- 跨会话持久化
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 了解用户角色偏好后及时保存到 `user` 类型
|
||||
2. 用户明确要求记住的内容立即保存
|
||||
3. 过时的记忆及时更新或删除
|
||||
4. 不保存可以从代码/git 推导出的信息
|
||||
5. 记忆是辅助参考,当前上下文和文件状态优先级更高
|
||||
22
skills/memory-management/versions/v0001/version.json
Normal file
22
skills/memory-management/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for memory management",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "持久化记忆管理。存储用户信息、项目上下文、偏好和反馈,实现跨会话记忆。",
|
||||
"name": "memory-management",
|
||||
"tools": ["memory"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "memory-management",
|
||||
"summary": "Memory Management — 持久化记忆系统,支持跨会话信息存储",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["memory"],
|
||||
"version": "v0001"
|
||||
}
|
||||
4
skills/officebench-mcp/current.json
Normal file
4
skills/officebench-mcp/current.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
|
||||
21
skills/officebench-mcp/skill.json
Normal file
21
skills/officebench-mcp/skill.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"created_at": "2026-05-27T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "Guidance for OfficeBench evaluation tasks. Use the registered mcp_officebench_* tools to inspect and edit OfficeBench files, spreadsheets, documents, emails, calendars, PDFs, and answer files.",
|
||||
"display_name": "officebench-mcp",
|
||||
"lineage": [],
|
||||
"name": "officebench-mcp",
|
||||
"owners": [
|
||||
"system"
|
||||
],
|
||||
"source_kind": "workspace",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"officebench",
|
||||
"mcp",
|
||||
"evaluation",
|
||||
"office"
|
||||
],
|
||||
"updated_at": "2026-05-27T00:00:00.000000+00:00"
|
||||
}
|
||||
|
||||
190
skills/officebench-mcp/versions/v0001/SKILL.md
Normal file
190
skills/officebench-mcp/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,190 @@
|
||||
---
|
||||
name: officebench-mcp
|
||||
description: Guidance for OfficeBench evaluation tasks. Use the registered mcp_officebench_* tools to inspect and edit OfficeBench files, spreadsheets, documents, emails, calendars, PDFs, and answer files.
|
||||
always: true
|
||||
tools:
|
||||
- mcp_officebench_excel_read_file
|
||||
- mcp_officebench_excel_set_cell
|
||||
- mcp_officebench_excel_delete_cell
|
||||
- mcp_officebench_excel_create_new_file
|
||||
- mcp_officebench_excel_convert_to_pdf
|
||||
- mcp_officebench_word_read_file
|
||||
- mcp_officebench_word_write_to_file
|
||||
- mcp_officebench_word_create_new_file
|
||||
- mcp_officebench_word_convert_to_pdf
|
||||
- mcp_officebench_email_list_emails
|
||||
- mcp_officebench_email_read_email
|
||||
- mcp_officebench_email_send_email
|
||||
- mcp_officebench_calendar_create_event
|
||||
- mcp_officebench_calendar_list_events
|
||||
- mcp_officebench_calendar_delete_event
|
||||
- mcp_officebench_pdf_read_file
|
||||
- mcp_officebench_pdf_convert_to_word
|
||||
- mcp_officebench_pdf_convert_to_image
|
||||
- mcp_officebench_ocr_recognize_file
|
||||
- mcp_officebench_shell_command
|
||||
- mcp_officebench_shell_list_directory
|
||||
- mcp_officebench_shell_read_file
|
||||
- mcp_officebench_shell_write_file
|
||||
- mcp_officebench_shell_copy_file
|
||||
- mcp_officebench_system_finish_task
|
||||
- mcp_officebench_system_get_status
|
||||
- mcp_officebench_image_convert_to_pdf
|
||||
---
|
||||
|
||||
# OfficeBench MCP Skill
|
||||
|
||||
Use this skill for OfficeBench evaluation runs. OfficeBench task files live in the OfficeBench MCP server, not in Beaver's local filesystem. Complete the task by calling real `mcp_officebench_*` tools.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. Use actual Beaver tool calls only. Do not print XML, DSML, JSON, or markdown that describes a tool call.
|
||||
2. Never invent tool names. If you need to find files, use `mcp_officebench_shell_list_directory` or `mcp_officebench_shell_command`.
|
||||
3. Do not use Beaver local filesystem, local runtime, local terminal, or local code tools for OfficeBench files.
|
||||
4. Paths are relative to `/testbed` in the OfficeBench MCP container, such as `data/score.xlsx`.
|
||||
5. If the task context gives a `workspace_id`, pass that same `workspace_id` argument in every OfficeBench MCP tool call that supports it.
|
||||
6. Inspect files before editing them.
|
||||
7. Verify the requested output file or edited cell exists before finishing.
|
||||
8. Finish every task with `mcp_officebench_system_finish_task`.
|
||||
|
||||
## Tool Names And Use
|
||||
|
||||
### Excel
|
||||
|
||||
Use these for `.xlsx` files:
|
||||
|
||||
- `mcp_officebench_excel_read_file`: read workbook sheets and cell values.
|
||||
- Required: `file_path`
|
||||
- Optional: `sheet_name`, `workspace_id`
|
||||
- `mcp_officebench_excel_set_cell`: write one cell.
|
||||
- Required: `file_path`, `row`, `col`, `value`
|
||||
- Optional: `sheet_name`, `workspace_id`
|
||||
- Rows and columns are 1-based.
|
||||
- `mcp_officebench_excel_delete_cell`: clear one cell.
|
||||
- Required: `file_path`, `row`, `col`
|
||||
- Optional: `sheet_name`, `workspace_id`
|
||||
- `mcp_officebench_excel_create_new_file`: create a workbook.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_excel_convert_to_pdf`: convert an Excel file to PDF.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
|
||||
Typical Excel sequence:
|
||||
|
||||
1. Call `mcp_officebench_shell_list_directory` on `data`.
|
||||
2. Call `mcp_officebench_excel_read_file` on the target workbook.
|
||||
3. Identify the exact row and column.
|
||||
4. Call `mcp_officebench_excel_set_cell`.
|
||||
5. Read the workbook again or use status/listing to verify.
|
||||
6. Call `mcp_officebench_system_finish_task`.
|
||||
|
||||
For the common task "change Bob's midterm1 score to 100 in score.xlsx", inspect `data/score.xlsx`, find Bob's row and the `midterm1` column, then call `mcp_officebench_excel_set_cell` with that row, that column, and value `100`.
|
||||
|
||||
### Word
|
||||
|
||||
Use these for `.docx` files:
|
||||
|
||||
- `mcp_officebench_word_read_file`: read all paragraphs.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_word_write_to_file`: overwrite or append text.
|
||||
- Required: `file_path`, `text`
|
||||
- Optional: `append`, `workspace_id`
|
||||
- `mcp_officebench_word_create_new_file`: create a new Word document.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_word_convert_to_pdf`: convert Word to PDF.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
|
||||
Preserve exact spelling, capitalization, punctuation, and line order from source files.
|
||||
|
||||
### Email
|
||||
|
||||
Use these for email tasks:
|
||||
|
||||
- `mcp_officebench_email_list_emails`: list available `.eml` messages.
|
||||
- Optional: `folder`, `workspace_id`
|
||||
- `mcp_officebench_email_read_email`: read one email.
|
||||
- Required: `email_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_email_send_email`: create/send an email artifact.
|
||||
- Required: `to`, `subject`, `body`
|
||||
- Optional: `attachments`, `workspace_id`
|
||||
|
||||
For email-search tasks, final answers should use plain text with literal lines like `Subject: ...`. Do not add markdown labels.
|
||||
|
||||
### Calendar
|
||||
|
||||
Use these for calendar `.ics` tasks:
|
||||
|
||||
- `mcp_officebench_calendar_list_events`: inspect calendar events.
|
||||
- Optional: `calendar_path`, `workspace_id`
|
||||
- `mcp_officebench_calendar_create_event`: create an event.
|
||||
- Required fields depend on the task; include summary/title, start, end, and target calendar when needed.
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_calendar_delete_event`: delete an event.
|
||||
- Required fields depend on the task; inspect events first.
|
||||
- Optional: `workspace_id`
|
||||
|
||||
Use the task's current date/time context when interpreting relative dates.
|
||||
|
||||
### PDF, OCR, And Images
|
||||
|
||||
Use these for PDF/image tasks:
|
||||
|
||||
- `mcp_officebench_pdf_read_file`: extract text from a PDF.
|
||||
- Required: `pdf_file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_pdf_convert_to_word`: convert PDF to Word.
|
||||
- Required: `pdf_file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_pdf_convert_to_image`: convert one PDF page to an image.
|
||||
- Required: `pdf_file_path`
|
||||
- Optional: `page_number`, `dpi`, `workspace_id`
|
||||
- `mcp_officebench_ocr_recognize_file`: OCR an image.
|
||||
- Required: `image_path`
|
||||
- Optional: `language`, `workspace_id`
|
||||
- `mcp_officebench_image_convert_to_pdf`: convert image to PDF.
|
||||
- Required: `image_path`
|
||||
- Optional: `output_path`, `workspace_id`
|
||||
|
||||
For conversion tasks, create the exact requested filename and verify it exists.
|
||||
|
||||
### Shell And System
|
||||
|
||||
Use these for safe file discovery and text files:
|
||||
|
||||
- `mcp_officebench_shell_list_directory`: list a directory.
|
||||
- Optional: `path`, `workspace_id`
|
||||
- `mcp_officebench_shell_read_file`: read text files such as `.txt`, `.csv`, `.json`, `.md`, `.xml`.
|
||||
- Required: `file_path`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_shell_write_file`: write text files.
|
||||
- Required: `file_path`, `content`
|
||||
- Optional: `append`, `workspace_id`
|
||||
- `mcp_officebench_shell_copy_file`: copy a file or directory.
|
||||
- Required: `source`, `destination`
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_shell_command`: run shell commands inside the OfficeBench MCP container.
|
||||
- Required: `command`
|
||||
- Optional: `workdir`, `workspace_id`
|
||||
- `mcp_officebench_system_get_status`: inspect filesystem/git status.
|
||||
- Optional: `workspace_id`
|
||||
- `mcp_officebench_system_finish_task`: mark the task complete and optionally write an answer.
|
||||
- Optional: `answer`, `workspace_id`
|
||||
|
||||
Prefer dedicated Office tools for Office documents. Use shell tools for listing directories, copying/renaming files, and reading/writing plain text.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Do not do any of the following:
|
||||
|
||||
- Do not call `mcp_officebench_find_in_workspace`; that tool does not exist.
|
||||
- Do not output `<tool_calls>`, `<invoke>`, DSML, or pseudo tool call text.
|
||||
- Do not answer "done" without calling the required OfficeBench tools.
|
||||
- Do not edit guessed paths without first listing or reading relevant files.
|
||||
- Do not use `/testbed` as a literal prefix in path arguments unless a tool explicitly asks for an absolute path.
|
||||
- Do not correct misspellings found in source data. Preserve source text exactly.
|
||||
|
||||
80
skills/officebench-mcp/versions/v0001/version.json
Normal file
80
skills/officebench-mcp/versions/v0001/version.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"change_reason": "Initial OfficeBench MCP skill for evaluation runs",
|
||||
"content_hash": "6afdd5a93ce552f39c1e285fc552059cfada7971e0d5bb91bcd56c6ca608ba17",
|
||||
"created_at": "2026-05-27T00:00:00.000000+00:00",
|
||||
"created_by": "codex",
|
||||
"frontmatter": {
|
||||
"always": true,
|
||||
"description": "Guidance for OfficeBench evaluation tasks. Use the registered mcp_officebench_* tools to inspect and edit OfficeBench files, spreadsheets, documents, emails, calendars, PDFs, and answer files.",
|
||||
"name": "officebench-mcp",
|
||||
"tools": [
|
||||
"mcp_officebench_excel_read_file",
|
||||
"mcp_officebench_excel_set_cell",
|
||||
"mcp_officebench_excel_delete_cell",
|
||||
"mcp_officebench_excel_create_new_file",
|
||||
"mcp_officebench_excel_convert_to_pdf",
|
||||
"mcp_officebench_word_read_file",
|
||||
"mcp_officebench_word_write_to_file",
|
||||
"mcp_officebench_word_create_new_file",
|
||||
"mcp_officebench_word_convert_to_pdf",
|
||||
"mcp_officebench_email_list_emails",
|
||||
"mcp_officebench_email_read_email",
|
||||
"mcp_officebench_email_send_email",
|
||||
"mcp_officebench_calendar_create_event",
|
||||
"mcp_officebench_calendar_list_events",
|
||||
"mcp_officebench_calendar_delete_event",
|
||||
"mcp_officebench_pdf_read_file",
|
||||
"mcp_officebench_pdf_convert_to_word",
|
||||
"mcp_officebench_pdf_convert_to_image",
|
||||
"mcp_officebench_ocr_recognize_file",
|
||||
"mcp_officebench_shell_command",
|
||||
"mcp_officebench_shell_list_directory",
|
||||
"mcp_officebench_shell_read_file",
|
||||
"mcp_officebench_shell_write_file",
|
||||
"mcp_officebench_shell_copy_file",
|
||||
"mcp_officebench_system_finish_task",
|
||||
"mcp_officebench_system_get_status",
|
||||
"mcp_officebench_image_convert_to_pdf"
|
||||
]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "officebench_mcp",
|
||||
"source_kind": "workspace"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "officebench-mcp",
|
||||
"summary": "OfficeBench MCP skill for using registered mcp_officebench_* tools correctly during evaluation runs.",
|
||||
"summary_hash": "914d6759650fce29884f648b84929e0482475c3ccd6601e9903c9b8b826dd874",
|
||||
"tool_hints": [
|
||||
"mcp_officebench_excel_read_file",
|
||||
"mcp_officebench_excel_set_cell",
|
||||
"mcp_officebench_excel_delete_cell",
|
||||
"mcp_officebench_excel_create_new_file",
|
||||
"mcp_officebench_excel_convert_to_pdf",
|
||||
"mcp_officebench_word_read_file",
|
||||
"mcp_officebench_word_write_to_file",
|
||||
"mcp_officebench_word_create_new_file",
|
||||
"mcp_officebench_word_convert_to_pdf",
|
||||
"mcp_officebench_email_list_emails",
|
||||
"mcp_officebench_email_read_email",
|
||||
"mcp_officebench_email_send_email",
|
||||
"mcp_officebench_calendar_create_event",
|
||||
"mcp_officebench_calendar_list_events",
|
||||
"mcp_officebench_calendar_delete_event",
|
||||
"mcp_officebench_pdf_read_file",
|
||||
"mcp_officebench_pdf_convert_to_word",
|
||||
"mcp_officebench_pdf_convert_to_image",
|
||||
"mcp_officebench_ocr_recognize_file",
|
||||
"mcp_officebench_shell_command",
|
||||
"mcp_officebench_shell_list_directory",
|
||||
"mcp_officebench_shell_read_file",
|
||||
"mcp_officebench_shell_write_file",
|
||||
"mcp_officebench_shell_copy_file",
|
||||
"mcp_officebench_system_finish_task",
|
||||
"mcp_officebench_system_get_status",
|
||||
"mcp_officebench_image_convert_to_pdf"
|
||||
],
|
||||
"version": "v0001"
|
||||
}
|
||||
|
||||
3
skills/outlook-mail/current.json
Normal file
3
skills/outlook-mail/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/outlook-mail/skill.json
Normal file
13
skills/outlook-mail/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "通过 Outlook MCP 进行邮件收发、日历管理和会议安排。支持 Graph API 和 on-prem Exchange。",
|
||||
"display_name": "outlook-mail",
|
||||
"lineage": [],
|
||||
"name": "outlook-mail",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["outlook", "email", "calendar", "mcp", "microsoft"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
150
skills/outlook-mail/versions/v0001/SKILL.md
Normal file
150
skills/outlook-mail/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,150 @@
|
||||
---
|
||||
name: outlook-mail
|
||||
description: 通过 Outlook MCP 进行邮件收发、日历管理和会议安排。支持 Graph API 和 on-prem Exchange。
|
||||
tools:
|
||||
- mcp_outlook_mcp_mail_list_folders
|
||||
- mcp_outlook_mcp_mail_list_messages
|
||||
- mcp_outlook_mcp_mail_search_messages
|
||||
- mcp_outlook_mcp_mail_get_message
|
||||
- mcp_outlook_mcp_mail_send_email
|
||||
- mcp_outlook_mcp_mail_reply_to_message
|
||||
- mcp_outlook_mcp_mail_forward_message
|
||||
- mcp_outlook_mcp_mail_move_message
|
||||
- mcp_outlook_mcp_mail_delta_sync
|
||||
- mcp_outlook_mcp_calendar_list_events
|
||||
- mcp_outlook_mcp_calendar_create_event
|
||||
- mcp_outlook_mcp_calendar_update_event
|
||||
- mcp_outlook_mcp_calendar_get_schedule
|
||||
- mcp_outlook_mcp_calendar_find_meeting_times
|
||||
- mcp_outlook_mcp_calendar_delta_sync
|
||||
---
|
||||
|
||||
# Outlook MCP — 邮件与日历管理
|
||||
|
||||
通过 MCP server 连接 Outlook(Microsoft Graph / on-prem Exchange),提供邮件和日历的完整操作能力。
|
||||
|
||||
## 邮件工具
|
||||
|
||||
### mcp_outlook_mcp_mail_list_folders
|
||||
列出 Outlook 邮件文件夹。
|
||||
- `top` (int, 默认 50): 返回数量上限
|
||||
|
||||
### mcp_outlook_mcp_mail_list_messages
|
||||
列出指定文件夹的邮件。
|
||||
- `folder` (str, 默认 "inbox"): 文件夹名
|
||||
- `top` (int, 默认 20): 返回条数
|
||||
- `skip` (int, 默认 0): 跳过的条数
|
||||
- `unread_only` (bool, 默认 false): 仅未读
|
||||
|
||||
### mcp_outlook_mcp_mail_search_messages
|
||||
搜索邮件(使用 Graph search 语义)。
|
||||
- `query` (str): 搜索关键词
|
||||
- `folder` (str | None): 限定文件夹
|
||||
- `top` (int, 默认 20): 返回条数
|
||||
|
||||
### mcp_outlook_mcp_mail_get_message
|
||||
读取单封邮件的完整内容。
|
||||
- `message_id` (str): 邮件 ID
|
||||
- `changekey` (str | None): EWS changekey(on-prem 需要)
|
||||
|
||||
### mcp_outlook_mcp_mail_send_email
|
||||
发送新邮件。**幂等操作**,支持 idempotency_key。
|
||||
- `subject` (str): 主题
|
||||
- `body` (str): 正文(支持 HTML)
|
||||
- `to_recipients` (list[str]): 收件人
|
||||
- `cc_recipients` (list[str] | None): 抄送
|
||||
- `bcc_recipients` (list[str] | None): 密送
|
||||
- `idempotency_key` (str | None): 幂等键,防止重复发送
|
||||
|
||||
### mcp_outlook_mcp_mail_reply_to_message
|
||||
回复一封邮件。
|
||||
- `message_id` (str): 原邮件 ID
|
||||
- `comment` (str): 回复内容
|
||||
- `changekey` (str | None): EWS changekey
|
||||
- `idempotency_key` (str | None)
|
||||
|
||||
### mcp_outlook_mcp_mail_forward_message
|
||||
转发邮件给其他人。
|
||||
- `message_id` (str): 原邮件 ID
|
||||
- `to_recipients` (list[str]): 转发目标
|
||||
- `comment` (str): 附加说明
|
||||
- `cc_recipients` (list[str] | None)
|
||||
- `changekey` (str | None)
|
||||
- `idempotency_key` (str | None)
|
||||
|
||||
### mcp_outlook_mcp_mail_move_message
|
||||
移动邮件到其他文件夹。
|
||||
- `message_id` (str): 邮件 ID
|
||||
- `destination_folder` (str): 目标文件夹
|
||||
- `changekey` (str | None)
|
||||
- `idempotency_key` (str | None)
|
||||
|
||||
### mcp_outlook_mcp_mail_delta_sync
|
||||
增量同步邮件变更。支持游标持久化,适合长期同步场景。
|
||||
- `folder` (str, 默认 "inbox"): 文件夹
|
||||
- `delta_link` (str | None): 增量链接(续传时提供)
|
||||
- `top` (int, 默认 50)
|
||||
- `persist_cursor` (bool, 默认 true): 是否持久化游标
|
||||
|
||||
## 日历工具
|
||||
|
||||
### mcp_outlook_mcp_calendar_list_events
|
||||
列出日历事件或日历视图。
|
||||
- `start_time` (str | None): ISO 开始时间,与 end_time 成对提供
|
||||
- `end_time` (str | None): ISO 结束时间
|
||||
- `top` (int, 默认 20)
|
||||
- `skip` (int, 默认 0)
|
||||
|
||||
### mcp_outlook_mcp_calendar_create_event
|
||||
创建日历事件或正式会议邀请。**幂等操作**。
|
||||
- `subject` (str): 主题
|
||||
- `start_time` (str): ISO 开始时间
|
||||
- `end_time` (str): ISO 结束时间
|
||||
- `timezone` (str, 默认 "UTC"): 时区
|
||||
- `body` (str | None): 正文
|
||||
- `location` (str | None): 地点
|
||||
- `attendees` (list[str] | None): 参会人
|
||||
- `is_online_meeting` (bool, 默认 false): 是否创建 Teams 会议
|
||||
- `online_meeting_provider` (str, 默认 "teamsForBusiness"): 在线会议提供商
|
||||
- `transaction_id` (str | None): 事务 ID
|
||||
- `idempotency_key` (str | None)
|
||||
|
||||
### mcp_outlook_mcp_calendar_update_event
|
||||
更新已有日历事件。
|
||||
- `event_id` (str): 事件 ID
|
||||
- `subject` / `start_time` / `end_time` / `timezone` / `body` / `location` / `attendees`: 可选更新字段
|
||||
- `idempotency_key` (str | None)
|
||||
|
||||
### mcp_outlook_mcp_calendar_get_schedule
|
||||
查询与会人忙闲状态。
|
||||
- `schedules` (list[str]): 要查询的人员列表
|
||||
- `start_time` (str): ISO 开始
|
||||
- `end_time` (str): ISO 结束
|
||||
- `availability_view_interval` (int, 默认 30): 时间间隔(分钟)
|
||||
- `timezone` (str, 默认 "UTC")
|
||||
|
||||
### mcp_outlook_mcp_calendar_find_meeting_times
|
||||
推荐最佳会议时间。
|
||||
- `attendees` (list[str]): 参会人
|
||||
- `start_time` (str): 时间范围开始
|
||||
- `end_time` (str): 时间范围结束
|
||||
- `duration_minutes` (int, 默认 30): 会议时长
|
||||
- `timezone` (str, 默认 "UTC")
|
||||
- `max_candidates` (int, 默认 10): 候选数
|
||||
|
||||
### mcp_outlook_mcp_calendar_delta_sync
|
||||
增量同步日历事件变更。
|
||||
- `start_time` (str): 同步窗口开始
|
||||
- `end_time` (str): 同步窗口结束
|
||||
- `delta_link` (str | None): 增量续传链接
|
||||
- `top` (int, 默认 50)
|
||||
- `persist_cursor` (bool, 默认 true)
|
||||
- `cursor_key` (str, 默认 "calendar:primary")
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 邮件操作优先使用幂等键(idempotency_key)防止重复发送
|
||||
2. 日历时间参数统一使用 ISO 8601 格式
|
||||
3. 增量同步时优先使用返回的 delta_link 续传,避免全量拉取
|
||||
4. 发送邮件前确认收件人地址格式正确
|
||||
5. 创建会议时明确时区,避免跨时区混淆
|
||||
22
skills/outlook-mail/versions/v0001/version.json
Normal file
22
skills/outlook-mail/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for Outlook MCP mail and calendar operations",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "通过 Outlook MCP 进行邮件收发、日历管理和会议安排。支持 Graph API 和 on-prem Exchange。",
|
||||
"name": "outlook-mail",
|
||||
"tools": ["mcp_outlook_mcp_mail_list_folders", "mcp_outlook_mcp_mail_list_messages", "mcp_outlook_mcp_mail_search_messages", "mcp_outlook_mcp_mail_get_message", "mcp_outlook_mcp_mail_send_email", "mcp_outlook_mcp_mail_reply_to_message", "mcp_outlook_mcp_mail_forward_message", "mcp_outlook_mcp_mail_move_message", "mcp_outlook_mcp_mail_delta_sync", "mcp_outlook_mcp_calendar_list_events", "mcp_outlook_mcp_calendar_create_event", "mcp_outlook_mcp_calendar_update_event", "mcp_outlook_mcp_calendar_get_schedule", "mcp_outlook_mcp_calendar_find_meeting_times", "mcp_outlook_mcp_calendar_delta_sync"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "outlook-mail",
|
||||
"summary": "Outlook MCP — 邮件与日历管理。通过 MCP server 连接 Outlook,提供邮件和日历的完整操作能力。",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["mcp_outlook_mcp_mail_list_folders", "mcp_outlook_mcp_mail_list_messages", "mcp_outlook_mcp_mail_search_messages", "mcp_outlook_mcp_mail_get_message", "mcp_outlook_mcp_mail_send_email", "mcp_outlook_mcp_mail_reply_to_message", "mcp_outlook_mcp_mail_forward_message", "mcp_outlook_mcp_mail_move_message", "mcp_outlook_mcp_mail_delta_sync", "mcp_outlook_mcp_calendar_list_events", "mcp_outlook_mcp_calendar_create_event", "mcp_outlook_mcp_calendar_update_event", "mcp_outlook_mcp_calendar_get_schedule", "mcp_outlook_mcp_calendar_find_meeting_times", "mcp_outlook_mcp_calendar_delta_sync"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/skills-admin/current.json
Normal file
3
skills/skills-admin/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/skills-admin/skill.json
Normal file
13
skills/skills-admin/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "技能(Skill)列表查看、内容加载和草稿管理。用于浏览已发布技能和创建新技能草稿。",
|
||||
"display_name": "skills-admin",
|
||||
"lineage": [],
|
||||
"name": "skills-admin",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["skills", "admin", "management", "draft"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
42
skills/skills-admin/versions/v0001/SKILL.md
Normal file
42
skills/skills-admin/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: skills-admin
|
||||
description: 技能(Skill)列表查看、内容加载和草稿管理。用于浏览已发布技能和创建新技能草稿。
|
||||
tools:
|
||||
- skills_list
|
||||
- skill_manage
|
||||
- skill_view
|
||||
---
|
||||
|
||||
# Skills Admin — 技能管理
|
||||
|
||||
查看已发布的技能列表、加载技能详情和创建新技能草稿。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### skills_list
|
||||
列出系统中所有可用技能及其描述。
|
||||
- 返回技能名称、描述和版本
|
||||
- 用于浏览当前可用的技能
|
||||
|
||||
### skill_view
|
||||
加载某个技能的完整正文或支持文件。
|
||||
- `name` (str): 技能名称
|
||||
- `file_path` (str | None): 可选的支持文件路径
|
||||
- 不传文件路径时返回 SKILL.md 主内容
|
||||
- 支持按需加载 references/、templates/ 等目录
|
||||
|
||||
### skill_manage
|
||||
创建新技能草稿(draft)。
|
||||
- `action` (str): 仅支持 "create_draft"
|
||||
- `name` (str): 技能名称
|
||||
- `description` (str): 技能描述
|
||||
- `content` (str): 技能正文(SKILL.md 格式)
|
||||
- 创建的草稿需经过 review → publish 流程
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 需要参考某个技能的详细内容时,先 `skills_list` 找到名称,再用 `skill_view` 加载
|
||||
2. 创建新技能时先写清楚 description,便于后续被 selector 选中
|
||||
3. 技能正文使用标准 frontmatter + Markdown 格式
|
||||
4. 支持文件放在 skill 目录的 references/、templates/、scripts/ 等子目录
|
||||
5. Draft 创建后需要走 review/publish 流程才能生效
|
||||
22
skills/skills-admin/versions/v0001/version.json
Normal file
22
skills/skills-admin/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for skills management",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "技能(Skill)列表查看、内容加载和草稿管理。用于浏览已发布技能和创建新技能草稿。",
|
||||
"name": "skills-admin",
|
||||
"tools": ["skills_list", "skill_manage", "skill_view"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "skills-admin",
|
||||
"summary": "Skills Admin — 技能列表查看、内容加载和草稿管理",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["skills_list", "skill_manage", "skill_view"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/terminal-operation/current.json
Normal file
3
skills/terminal-operation/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/terminal-operation/skill.json
Normal file
13
skills/terminal-operation/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "Shell 命令执行、后台进程管理和 Python 代码执行。支持超时控制和后台运行。",
|
||||
"display_name": "terminal-operation",
|
||||
"lineage": [],
|
||||
"name": "terminal-operation",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["terminal", "shell", "command", "process", "execution"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
46
skills/terminal-operation/versions/v0001/SKILL.md
Normal file
46
skills/terminal-operation/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
name: terminal-operation
|
||||
description: Shell 命令执行、后台进程管理和 Python 代码执行。支持超时控制和后台运行。
|
||||
tools:
|
||||
- terminal
|
||||
- process
|
||||
- execute_code
|
||||
---
|
||||
|
||||
# Terminal Operation — 终端与进程管理
|
||||
|
||||
Shell 命令执行、后台进程管理和 Python 代码执行工具集。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### terminal
|
||||
执行 shell 命令。
|
||||
- `command` (str): 要执行的命令
|
||||
- `working_dir` (str, 默认 "."): 工作目录
|
||||
- `timeout` (int, 默认 60): 超时秒数(最大 600)
|
||||
- `background` (bool, 默认 false): 是否后台运行
|
||||
- 后台运行时返回 process_id,可通过 process 工具管理
|
||||
|
||||
### process
|
||||
管理后台进程。
|
||||
- `action` (str): `list` | `log` | `kill`
|
||||
- `process_id` (str | None): 进程 ID
|
||||
- `list`: 列出所有后台进程
|
||||
- `log`: 查看进程日志(最后 12000 字节)
|
||||
- `kill`: 终止进程(先 SIGTERM,5 秒后 SIGKILL)
|
||||
|
||||
### execute_code
|
||||
执行 Python 代码片段。
|
||||
- `code` (str): Python 代码
|
||||
- `language` (str, 默认 "python"): 仅支持 python
|
||||
- `timeout` (int, 默认 30, 最大 120): 执行超时
|
||||
- `working_dir` (str, 默认 "."): 工作目录
|
||||
- 适合快速验证脚本逻辑,不适合长期运行任务
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 长期运行任务使用 `background=true`
|
||||
2. 执行危险命令(rm -rf、dd、格式化等)前必须确认用户意图
|
||||
3. `execute_code` 适合轻量脚本验证,重型任务用 `terminal`
|
||||
4. 后台进程用完后及时 kill 清理
|
||||
5. 注意命令注入风险,不要直接拼接用户输入
|
||||
22
skills/terminal-operation/versions/v0001/version.json
Normal file
22
skills/terminal-operation/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for terminal and process management",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "Shell 命令执行、后台进程管理和 Python 代码执行。支持超时控制和后台运行。",
|
||||
"name": "terminal-operation",
|
||||
"tools": ["terminal", "process", "execute_code"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "terminal-operation",
|
||||
"summary": "Terminal Operation — Shell 命令执行、后台进程管理、Python 代码执行",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["terminal", "process", "execute_code"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/utility-tools/current.json
Normal file
3
skills/utility-tools/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/utility-tools/skill.json
Normal file
13
skills/utility-tools/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "辅助工具集,包括任务分解(Todo)、任务委托(Delegate)、子 Agent 生成(Spawn)、消息发送和需求澄清(Clarify)。",
|
||||
"display_name": "utility-tools",
|
||||
"lineage": [],
|
||||
"name": "utility-tools",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["utility", "delegate", "todo", "spawn", "clarify"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
52
skills/utility-tools/versions/v0001/SKILL.md
Normal file
52
skills/utility-tools/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
name: utility-tools
|
||||
description: 辅助工具集,包括任务分解(Todo)、任务委托(Delegate)、子 Agent 生成(Spawn)、消息发送和需求澄清。
|
||||
tools:
|
||||
- clarify
|
||||
- delegate
|
||||
- send_message
|
||||
- spawn
|
||||
- todo
|
||||
---
|
||||
|
||||
# Utility Tools — 辅助工具集
|
||||
|
||||
任务管理、委托和协作的辅助工具。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### todo (TodoWrite)
|
||||
创建和管理任务列表,跟踪复杂任务的进度。
|
||||
- 适合多步骤、复杂任务时使用
|
||||
- 标记当前正在进行的任务
|
||||
- 完成后立即更新状态
|
||||
|
||||
### delegate (DelegateTool)
|
||||
将任务委托给专门的子 Agent 执行。
|
||||
- 适合独立、可并行的工作
|
||||
- 委托时提供清晰的上下文和目标
|
||||
- 子 Agent 完成后再整合结果
|
||||
|
||||
### spawn (SpawnTool)
|
||||
启动新的 Agent 实例执行特定任务。
|
||||
- 适合需要独立运行的工作
|
||||
- 支持后台运行(不阻塞主流程)
|
||||
|
||||
### send_message (SendMessageTool)
|
||||
与其他 Agent 或团队成员通信。
|
||||
- 适合多 Agent 协作场景
|
||||
- 消息会直接送达目标
|
||||
|
||||
### clarify (ClarifyTool)
|
||||
当需求不明确时向用户提问澄清。
|
||||
- 提供 2-4 个选项供用户选择
|
||||
- 附带推荐选项和理由
|
||||
- 避免模糊提问,给出明确建议
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 复杂任务先创建 Todo 列表,明确步骤
|
||||
2. 可并行的工作使用 Delegate/Spawn 分散执行
|
||||
3. 需求不明确时主动 Clarify,不要猜测
|
||||
4. 多 Agent 协作时保持通信简洁
|
||||
5. 记得到 todo list 更新进度
|
||||
22
skills/utility-tools/versions/v0001/version.json
Normal file
22
skills/utility-tools/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for utility and delegation tools",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "辅助工具集,包括任务分解(Todo)、任务委托(Delegate)、子 Agent 生成(Spawn)、消息发送和需求澄清。",
|
||||
"name": "utility-tools",
|
||||
"tools": ["clarify", "delegate", "send_message", "spawn", "todo"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "utility-tools",
|
||||
"summary": "Utility Tools — 任务管理、委托和协作辅助工具集",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"version": "v0001"
|
||||
}
|
||||
3
skills/web-operation/current.json
Normal file
3
skills/web-operation/current.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"current_version": "v0001"
|
||||
}
|
||||
13
skills/web-operation/skill.json
Normal file
13
skills/web-operation/skill.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "网页内容抓取和搜索引擎查询。支持任意 URL 抓取、多搜索引擎和结构化数据提取。",
|
||||
"display_name": "web-operation",
|
||||
"lineage": [],
|
||||
"name": "web-operation",
|
||||
"owners": ["system"],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": ["web", "search", "fetch", "crawl"],
|
||||
"updated_at": "2026-05-26T00:00:00.000000+00:00"
|
||||
}
|
||||
36
skills/web-operation/versions/v0001/SKILL.md
Normal file
36
skills/web-operation/versions/v0001/SKILL.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
name: web-operation
|
||||
description: 网页内容抓取和搜索引擎查询。支持任意 URL 抓取、多搜索引擎和结构化数据提取。
|
||||
tools:
|
||||
- web_fetch
|
||||
- web_search
|
||||
---
|
||||
|
||||
# Web Operation — 网络抓取与搜索
|
||||
|
||||
网页抓取和网络搜索工具集。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### web_fetch
|
||||
获取指定 URL 的网页内容并转换为 Markdown。
|
||||
- 支持 HTML → Markdown 自动转换
|
||||
- 可使用 prompt 参数提取特定信息
|
||||
- 结果由 AI 总结后返回
|
||||
- HTTP URL 自动升级为 HTTPS
|
||||
- 含 15 分钟缓存
|
||||
|
||||
### web_search
|
||||
搜索引擎查询,获取最新网络信息。
|
||||
- 支持 domain 过滤(include/block)
|
||||
- 搜索当前日期的信息使用正确年份
|
||||
- 返回结果包含 URL 链接
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 优先使用 `web_search` 搜索信息,再用 `web_fetch` 深入阅读
|
||||
2. 获取动态/需要认证的页面可能失败,此时尝试简化请求或换源
|
||||
3. 抓取 API 文档时注意区分 REST API 和 GraphQL
|
||||
4. 搜索结果必须标注来源链接
|
||||
5. 避免短时间内大量请求同一站点(限频)
|
||||
6. 不抓取需要登录认证的私密页面
|
||||
22
skills/web-operation/versions/v0001/version.json
Normal file
22
skills/web-operation/versions/v0001/version.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"change_reason": "Initial skill for web fetching and searching",
|
||||
"content_hash": "placeholder",
|
||||
"created_at": "2026-05-26T00:00:00.000000+00:00",
|
||||
"created_by": "system",
|
||||
"frontmatter": {
|
||||
"description": "网页内容抓取和搜索引擎查询。支持任意 URL 抓取、多搜索引擎和结构化数据提取。",
|
||||
"name": "web-operation",
|
||||
"tools": ["web_fetch", "web_search"]
|
||||
},
|
||||
"parent_version": null,
|
||||
"provenance": {
|
||||
"source": "initial_skills",
|
||||
"source_kind": "initial"
|
||||
},
|
||||
"review_state": "published",
|
||||
"skill_name": "web-operation",
|
||||
"summary": "Web Operation — 网页抓取与网络搜索工具集",
|
||||
"summary_hash": "placeholder",
|
||||
"tool_hints": ["web_fetch", "web_search"],
|
||||
"version": "v0001"
|
||||
}
|
||||
Reference in New Issue
Block a user