feat(coordinator): 添加团队节点默认最大工具迭代次数配置

添加 DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS 配置项以控制团队节点的最大工具迭代次数,
并修改 LocalAgentRunner 中的逻辑来使用此默认值当 envelope 中未指定时。

fix(runtime): 修复团队节点运行成功判断逻辑

更新运行成功判断条件,将 finish_reason 为 "max_tool_iterations_finalized" 的情况
视为运行失败,并添加对原始工具调用输出的检测,避免将其误判为成功完成。

feat(mcp): 添加团队工作流MCP工具类别支持

增加新的本地MCP工具类别 "team_workflow" 及其对应的工具创建功能,
为团队工作流提供本地工具支持。

refactor(engine): 调整AgentLoop最大工具迭代次数设置

将 AgentProfile 中的默认 max_tool_iterations 从 30 增加到 100,
同时移除 TaskExecutionPlanner 构造函数中的重复参数传递。

perf(mcp): 优化MCP连接管理避免重复连接

添加 mcp_connected 标志来跟踪MCP连接状态,确保 connect_all 只执行一次,
提高性能并避免不必要的重复连接。

refactor(skills): 移除技能团队模板相关功能

移除与技能团队模板相关的代码,包括解析、存储和处理逻辑,
简化技能记录结构和加载流程。

feat(process): 增强会话过程投影器功能

添加技能激活快照事件处理,改进团队运行完成消息显示,
并增强技能激活事件的时间戳记录功能。

refactor(tasks): 简化任务尝试编排器团队执行逻辑

移除团队执行相关代码,将所有任务统一按单步执行处理,
简化任务编排器的复杂度并提升执行效率。

fix(evidence): 修复节点证据评估中需求验证逻辑

更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证,
只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
This commit is contained in:
2026-06-26 16:36:29 +08:00
parent 53b13e8eac
commit 520a21a027
360 changed files with 13271 additions and 1848 deletions

View File

@ -9,6 +9,7 @@ from beaver.engine.providers import ProviderBundle
from beaver.tasks.evidence import EvidenceBuilder, evaluate_node_evidence from beaver.tasks.evidence import EvidenceBuilder, evaluate_node_evidence
from .models import DelegationEnvelope, NodeRunResult from .models import DelegationEnvelope, NodeRunResult
from .runtime_defaults import DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS
class LocalAgentRunner: class LocalAgentRunner:
@ -55,7 +56,11 @@ class LocalAgentRunner:
pinned_skill_names=envelope.inherited_pinned_skills, pinned_skill_names=envelope.inherited_pinned_skills,
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts, pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
allowed_tool_names=envelope.allowed_tool_names, allowed_tool_names=envelope.allowed_tool_names,
max_tool_iterations=envelope.max_tool_iterations, max_tool_iterations=(
envelope.max_tool_iterations
if envelope.max_tool_iterations is not None
else DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS
),
allow_candidate_generation=allow_candidate_generation, allow_candidate_generation=allow_candidate_generation,
) )
loaded = target_loop.boot() loaded = target_loop.boot()
@ -70,7 +75,8 @@ class LocalAgentRunner:
envelope.required_evidence, envelope.required_evidence,
result.output_text, result.output_text,
) )
run_succeeded = result.finish_reason == "stop" raw_tool_call_output = self._looks_like_raw_tool_call(result.output_text)
run_succeeded = result.finish_reason in {"stop", "max_tool_iterations_finalized"} and not raw_tool_call_output
if not run_succeeded: if not run_succeeded:
completion_status = "failed" completion_status = "failed"
elif evidence_gaps: elif evidence_gaps:
@ -80,6 +86,9 @@ class LocalAgentRunner:
success = completion_status == "succeeded" success = completion_status == "succeeded"
if completion_status == "partial": if completion_status == "partial":
error = "; ".join(evidence_gaps) error = "; ".join(evidence_gaps)
else:
if raw_tool_call_output:
error = "finalized output is a raw tool call"
else: else:
error = None if success else (result.output_text or result.finish_reason) error = None if success else (result.output_text or result.finish_reason)
return NodeRunResult( return NodeRunResult(
@ -169,3 +178,16 @@ class LocalAgentRunner:
"If no published skill matches, return [] and let the node continue without skills." "If no published skill matches, return [] and let the node continue without skills."
) )
return "\n\n".join(sections) return "\n\n".join(sections)
@staticmethod
def _looks_like_raw_tool_call(output_text: str | None) -> bool:
text = (output_text or "").strip()
if not text:
return False
markers = (
"<DSMLtool_calls>",
"<DSMLinvoke",
"<tool_call",
"<function=",
)
return any(marker in text for marker in markers)

View File

@ -0,0 +1,3 @@
"""Runtime defaults shared by Beaver team planning and execution."""
DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS = 100

View File

@ -48,8 +48,6 @@ class SkillContext:
content_hash: str = "" content_hash: str = ""
activation_reason: str = "selected" activation_reason: str = "selected"
tool_hints: list[str] = field(default_factory=list) tool_hints: list[str] = field(default_factory=list)
team_template: dict[str, Any] | None = None
team_template_warnings: list[str] = field(default_factory=list)
@dataclass(slots=True) @dataclass(slots=True)

View File

@ -106,6 +106,7 @@ class EngineLoadResult:
task_execution_planner: TaskExecutionPlanner | None = None task_execution_planner: TaskExecutionPlanner | None = None
mcp_manager: MCPConnectionManager | None = None mcp_manager: MCPConnectionManager | None = None
mcp_report: dict[str, dict] = field(default_factory=dict) mcp_report: dict[str, dict] = field(default_factory=dict)
mcp_connected: bool = False
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False) closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
closed: bool = False closed: bool = False
@ -317,10 +318,7 @@ class EngineLoader:
draft_service=draft_service, draft_service=draft_service,
) )
task_service = self._task_service or TaskService(workspace / "tasks") task_service = self._task_service or TaskService(workspace / "tasks")
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner( task_execution_planner = self._task_execution_planner or TaskExecutionPlanner()
task_skill_resolver=task_skill_resolver,
tool_registry=tool_registry,
)
mcp_manager = MCPConnectionManager( mcp_manager = MCPConnectionManager(
self.config.tools.mcp_servers, self.config.tools.mcp_servers,
authz_config=self.config.authz, authz_config=self.config.authz,

View File

@ -53,7 +53,7 @@ class AgentProfile:
max_tokens: int | None = None max_tokens: int | None = None
max_context_messages: int = 1000 max_context_messages: int = 1000
temperature: float = 0.2 temperature: float = 0.2
max_tool_iterations: int = 30 max_tool_iterations: int = 100
@dataclass(slots=True) @dataclass(slots=True)
@ -99,8 +99,8 @@ class _WebSearchLoopGuard:
return None return None
query = str(payload.get("query") or self._last_query or "").strip() query = str(payload.get("query") or self._last_query or "").strip()
is_low_quality = payload.get("success") is False or payload.get("quality") == "low" is_failed_search = payload.get("success") is False
if not is_low_quality: if not is_failed_search:
self._reset() self._reset()
self._last_query = query self._last_query = query
return None return None
@ -435,7 +435,9 @@ class AgentLoop:
if include_tools and mcp_manager is not None: if include_tools and mcp_manager is not None:
started_at = perf_counter() started_at = perf_counter()
try: try:
if not loaded.mcp_connected:
loaded.mcp_report = await mcp_manager.connect_all(tool_registry) loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
loaded.mcp_connected = True
loaded.tools = [spec.name for spec in tool_registry.list_specs()] loaded.tools = [spec.name for spec in tool_registry.list_specs()]
finally: finally:
add_latency("mcp_ms", started_at) add_latency("mcp_ms", started_at)
@ -752,6 +754,11 @@ class AgentLoop:
"memory_store": memory_service.get_store(), "memory_store": memory_service.get_store(),
"tool_registry": tool_registry, "tool_registry": tool_registry,
"skills_loader": skills_loader, "skills_loader": skills_loader,
"loaded": loaded,
"agent_loop": self,
"provider_bundle": bundle,
"user_message": task,
"attempt_index": attempt_index,
"draft_service": getattr(loaded, "draft_service", None), "draft_service": getattr(loaded, "draft_service", None),
"beaver_config": loaded.config, "beaver_config": loaded.config,
"task_id": task_id, "task_id": task_id,
@ -764,6 +771,7 @@ class AgentLoop:
"session_id": resolved_session_id, "session_id": resolved_session_id,
"task_id": task_id, "task_id": task_id,
"run_id": resolved_run_id, "run_id": resolved_run_id,
"parent_session_id": parent_session_id,
"allowed_tool_names": ( "allowed_tool_names": (
None if allowed_tool_names is None else list(allowed_tool_names) None if allowed_tool_names is None else list(allowed_tool_names)
), ),

View File

@ -29,6 +29,7 @@ LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"}, "local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"}, "local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"}, "local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
"local_team_workflow_mcp": {"category": "team_workflow", "display_name": "本地 Agent Team Workflow 工具"},
} }

View File

@ -56,6 +56,7 @@ LOCAL_TOOL_CATEGORIES = {
"coordination": "Beaver Local Coordination Tools", "coordination": "Beaver Local Coordination Tools",
"scheduler": "Beaver Local Scheduler Tools", "scheduler": "Beaver Local Scheduler Tools",
"web": "Beaver Local Web Tools", "web": "Beaver Local Web Tools",
"team_workflow": "Beaver Local Team Workflow Tools",
} }
@ -129,6 +130,10 @@ def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], Too
ObjectBackedTool(WebFetchTool()), ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()), ObjectBackedTool(WebSearchTool()),
] ]
elif category == "team_workflow":
from beaver.team_workflows.mcp_tools import create_team_workflow_tools
tools = create_team_workflow_tools()
else: else:
raise ValueError(f"Unknown local tool category: {category}") raise ValueError(f"Unknown local tool category: {category}")
return tools, context return tools, context

View File

@ -68,7 +68,7 @@ class AgentService:
self.profile.max_tokens = None self.profile.max_tokens = None
self.profile.temperature = 0.2 self.profile.temperature = 0.2
self.profile.max_context_messages = 1000 self.profile.max_context_messages = 1000
self.profile.max_tool_iterations = 30 self.profile.max_tool_iterations = 100
if defaults.max_tokens is not None: if defaults.max_tokens is not None:
self.profile.max_tokens = max(1, defaults.max_tokens) self.profile.max_tokens = max(1, defaults.max_tokens)
if defaults.temperature is not None: if defaults.temperature is not None:

View File

@ -17,6 +17,7 @@ class SessionProcessProjector:
runs: dict[str, dict[str, Any]] = {} runs: dict[str, dict[str, Any]] = {}
events: list[dict[str, Any]] = [] events: list[dict[str, Any]] = []
artifacts: list[dict[str, Any]] = [] artifacts: list[dict[str, Any]] = []
projected_skill_activation_run_ids: set[str] = set()
def add_event( def add_event(
*, *,
@ -186,6 +187,38 @@ class SessionProcessProjector:
}, },
) )
elif record.event_type == "skill_activation_snapshotted":
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
receipts = [
item
for item in payload.get("receipts") or []
if isinstance(item, dict)
]
selected_skill_names = _receipt_skill_names(receipts)
if selected_skill_names:
projected_skill_activation_run_ids.add(str(run_id))
add_event(
event_id=_event_id(record, "skill-activation"),
run_id=str(run_id),
parent_run_id=parent_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=_receipt_started_at(receipts) or created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": selected_skill_names,
"activation_reasons": _receipt_reasons(receipts),
"receipts": receipts,
},
)
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}: elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
team_success = bool(payload.get("team_success")) team_success = bool(payload.get("team_success"))
root["status"] = "running" root["status"] = "running"
@ -203,7 +236,7 @@ class SessionProcessProjector:
actor_type="system", actor_type="system",
actor_id="team", actor_id="team",
actor_name="Task Team", actor_name="Task Team",
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"), text="Team completed" if team_success else "Team 执行未完成 / 子节点失败",
created_at=created_at, created_at=created_at,
status="done" if team_success else "error", status="done" if team_success else "error",
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids}, metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
@ -316,7 +349,10 @@ class SessionProcessProjector:
"skill_names": activated_skill_names, "skill_names": activated_skill_names,
}, },
} }
if activated_skill_names: if activated_skill_names and main_run_id not in projected_skill_activation_run_ids:
skill_created_at = _activated_skill_started_at(run_record) or (
run_record.started_at if run_record is not None else None
) or created_at
add_event( add_event(
event_id=_event_id(record, "synthesis-skills"), event_id=_event_id(record, "synthesis-skills"),
run_id=main_run_id, run_id=main_run_id,
@ -326,7 +362,7 @@ class SessionProcessProjector:
actor_id="skill-selector", actor_id="skill-selector",
actor_name="Skill Selector", actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.", text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
created_at=created_at, created_at=skill_created_at,
status="done", status="done",
metadata={ metadata={
"task_id": task_id, "task_id": task_id,
@ -439,6 +475,48 @@ def _activated_skill_reasons(run_record: Any | None) -> list[str]:
return reasons return reasons
def _activated_skill_started_at(run_record: Any | None) -> str | None:
if run_record is None:
return None
timestamps = [
str(getattr(receipt, "activated_at", "") or "").strip()
for receipt in getattr(run_record, "activated_skills", []) or []
]
timestamps = [value for value in timestamps if value]
if not timestamps:
return None
return sorted(timestamps)[0]
def _receipt_skill_names(receipts: list[dict[str, Any]]) -> list[str]:
names = []
for receipt in receipts:
skill_name = str(receipt.get("skill_name") or "").strip()
if skill_name:
names.append(skill_name)
return list(dict.fromkeys(names))
def _receipt_reasons(receipts: list[dict[str, Any]]) -> list[str]:
reasons = []
for receipt in receipts:
reason = str(receipt.get("activation_reason") or "").strip()
if reason:
reasons.append(reason)
return reasons
def _receipt_started_at(receipts: list[dict[str, Any]]) -> str | None:
timestamps = [
str(receipt.get("activated_at") or "").strip()
for receipt in receipts
]
timestamps = [value for value in timestamps if value]
if not timestamps:
return None
return sorted(timestamps)[0]
def _tool_call_name(tool_call: dict[str, Any]) -> str: def _tool_call_name(tool_call: dict[str, Any]) -> str:
function_payload = tool_call.get("function") function_payload = tool_call.get("function")
if isinstance(function_payload, dict): if isinstance(function_payload, dict):

View File

@ -140,8 +140,6 @@ class SkillAssembler:
content_hash=record.content_hash or "" if record is not None else "", content_hash=record.content_hash or "" if record is not None else "",
activation_reason="llm_selected", activation_reason="llm_selected",
tool_hints=list(record.tool_hints) if record is not None else [], tool_hints=list(record.tool_hints) if record is not None else [],
team_template=getattr(record, "team_template", None) if record is not None else None,
team_template_warnings=list(getattr(record, "team_template_warnings", [])) if record is not None else [],
) )
) )
return activated_skills return activated_skills

View File

@ -28,7 +28,6 @@ from .utils import (
check_requirements, check_requirements,
escape_xml, escape_xml,
extract_required_tool_names, extract_required_tool_names,
extract_skill_team_template,
get_missing_requirements, get_missing_requirements,
parse_frontmatter, parse_frontmatter,
parse_skill_metadata_blob, parse_skill_metadata_blob,
@ -50,8 +49,6 @@ class SkillRecord:
tool_hints: list[str] = field(default_factory=list) tool_hints: list[str] = field(default_factory=list)
frontmatter: dict[str, Any] = field(default_factory=dict) frontmatter: dict[str, Any] = field(default_factory=dict)
description: str = "" description: str = ""
team_template: dict[str, Any] | None = None
team_template_warnings: list[str] = field(default_factory=list)
class SkillsLoader: class SkillsLoader:
@ -116,7 +113,6 @@ class SkillsLoader:
continue continue
normalized_frontmatter = dict(frontmatter) normalized_frontmatter = dict(frontmatter)
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
template_result = extract_skill_team_template(body)
record = SkillRecord( record = SkillRecord(
name=name, name=name,
path=skill_file, path=skill_file,
@ -131,8 +127,6 @@ class SkillsLoader:
), ),
frontmatter=normalized_frontmatter, frontmatter=normalized_frontmatter,
description=str(frontmatter.get("description") or summarize_body(body) or name), description=str(frontmatter.get("description") or summarize_body(body) or name),
team_template=template_result.template,
team_template_warnings=template_result.warnings,
) )
if filter_unavailable and not self._record_available(record): if filter_unavailable and not self._record_available(record):
continue continue
@ -152,7 +146,6 @@ class SkillsLoader:
else: else:
path = self.workspace_skills / name / "versions" / loaded.version.version / "SKILL.md" path = self.workspace_skills / name / "versions" / loaded.version.version / "SKILL.md"
_frontmatter, body = parse_frontmatter(loaded.content) _frontmatter, body = parse_frontmatter(loaded.content)
template_result = extract_skill_team_template(body)
record = SkillRecord( record = SkillRecord(
name=name, name=name,
path=path, path=path,
@ -167,8 +160,6 @@ class SkillsLoader:
), ),
frontmatter=dict(loaded.version.frontmatter), frontmatter=dict(loaded.version.frontmatter),
description=str(loaded.version.frontmatter.get("description") or loaded.version.summary or name), description=str(loaded.version.frontmatter.get("description") or loaded.version.summary or name),
team_template=template_result.template,
team_template_warnings=template_result.warnings,
) )
if filter_unavailable and not self._record_available(record): if filter_unavailable and not self._record_available(record):
continue continue

View File

@ -17,7 +17,6 @@ import json
import os import os
import re import re
import shutil import shutil
from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -85,27 +84,6 @@ def strip_frontmatter(content: str) -> str:
return body return body
@dataclass(slots=True)
class SkillTeamTemplateParseResult:
template: dict[str, Any] | None = None
warnings: list[str] = field(default_factory=list)
def extract_skill_team_template(body: str) -> SkillTeamTemplateParseResult:
matches = re.findall(r"```beaver-team-template\s*\n(.*?)\n```", body, re.DOTALL)
if not matches:
return SkillTeamTemplateParseResult()
if len(matches) != 1:
return SkillTeamTemplateParseResult(warnings=["skill defines multiple team templates"])
try:
template = json.loads(matches[0])
except json.JSONDecodeError:
return SkillTeamTemplateParseResult(warnings=["team template JSON is invalid"])
if not isinstance(template, dict) or not isinstance(template.get("nodes", []), list):
return SkillTeamTemplateParseResult(warnings=["team template must be an object with a nodes list"])
return SkillTeamTemplateParseResult(template=template)
def extract_required_tool_names(body: str) -> list[str]: def extract_required_tool_names(body: str) -> list[str]:
"""从 canonical skill 正文的 `## Required Tools` 段落提取工具名。 """从 canonical skill 正文的 `## Required Tools` 段落提取工具名。

View File

@ -5,12 +5,11 @@ from __future__ import annotations
from time import perf_counter from time import perf_counter
from typing import Any, Callable from typing import Any, Callable
from beaver.coordinator.models import ExecutionNode, TeamRunResult
from beaver.engine import AgentRunResult from beaver.engine import AgentRunResult
from beaver.engine.context import SkillContext from beaver.engine.context import SkillContext
from beaver.prompts.main_agent import normalize_main_agent_prompt_locale from beaver.prompts.main_agent import normalize_main_agent_prompt_locale
from .evidence import EvidenceBuilder, RunEvidence, TaskEvidencePacket, render_task_evidence from .evidence import EvidenceBuilder, TaskEvidencePacket, render_task_evidence
from .models import TaskRecord from .models import TaskRecord
from .planner import TaskExecutionPlan from .planner import TaskExecutionPlan
@ -46,7 +45,7 @@ class TaskAttemptOrchestrator:
output_language_instruction = self._output_language_instruction(prompt_locale) output_language_instruction = self._output_language_instruction(prompt_locale)
provider_bundle = kwargs.get("provider_bundle") or self.make_provider_bundle_for_task(self.loaded, kwargs) provider_bundle = kwargs.get("provider_bundle") or self.make_provider_bundle_for_task(self.loaded, kwargs)
kwargs = dict(kwargs) kwargs = dict(kwargs)
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None) kwargs.pop("team_provider_bundle_factory", None)
kwargs["provider_bundle"] = provider_bundle kwargs["provider_bundle"] = provider_bundle
attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1 attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
@ -87,75 +86,17 @@ class TaskAttemptOrchestrator:
**plan.to_event_payload(), **plan.to_event_payload(),
}, },
) )
team_summaries: list[str] = []
team_execution_context = ""
team_result: TeamRunResult | None = None
if plan.is_team: if plan.is_team:
team_result, team_error = await self._run_team_for_task( plan = TaskExecutionPlan.single(
plan, "legacy_planner_team_ignored",
task=task, planner_adaptation=plan.planner_adaptation,
parent_session_id=kwargs["session_id"],
provider_bundle_factory=team_provider_bundle_factory
or self._build_team_provider_bundle_factory(kwargs),
) )
if team_result is not None: outcome_metadata = {
team_summaries = [self._team_summary_for_validation(team_result)] "task_outcome": "single",
team_packet = TaskEvidencePacket( "incomplete_node_ids": [],
task_id=task.task_id, "node_statuses": {},
attempt_index=attempt_index, "evidence_gaps": {},
main_run=None, }
team_runs=self._team_run_evidence(team_result),
team_node_results=list(team_result.node_results),
final_output="",
)
team_execution_context = self._join_context(
self._team_execution_context(plan, team_result),
"Rendered team evidence:\n" + render_task_evidence(team_packet),
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": team_result.run_ids,
"team_success": team_result.success,
"node_results": self._team_node_results_for_event(plan, team_result),
"reason": plan.reason,
"error": None if team_result.success else "one or more team nodes failed",
},
)
else:
team_summaries = [f"Team execution failed: {team_error}"]
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": [],
"team_success": False,
"reason": plan.reason,
"error": team_error,
},
)
outcome_context, incomplete_prefix, outcome_metadata = self._team_synthesis_outcome(
plan,
team_result,
prompt_locale=prompt_locale,
)
if plan.is_team:
team_execution_context = self._join_context(outcome_context, team_execution_context)
attempt_kwargs = dict(kwargs) attempt_kwargs = dict(kwargs)
attempt_kwargs.update( attempt_kwargs.update(
@ -171,22 +112,15 @@ class TaskAttemptOrchestrator:
attempt_kwargs["execution_context"] = self._join_context( attempt_kwargs["execution_context"] = self._join_context(
base_execution_context, base_execution_context,
output_language_instruction, output_language_instruction,
team_execution_context,
) )
if plan.is_team and team_execution_context:
attempt_kwargs["include_tools"] = False
attempt_kwargs["max_tool_iterations"] = 0
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context( attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task, task=task,
user_message=message, user_message=message,
attempt_index=attempt_index, attempt_index=attempt_index,
plan=plan, plan=plan,
team_summaries=team_summaries,
) )
result = await runner(message, **attempt_kwargs) result = await runner(message, **attempt_kwargs)
if outcome_metadata["task_outcome"] == "incomplete":
result.output_text = self._apply_incomplete_prefix(result.output_text, incomplete_prefix)
self._append_task_observation( self._append_task_observation(
session_manager, session_manager,
task.session_id, task.session_id,
@ -210,7 +144,6 @@ class TaskAttemptOrchestrator:
task=task, task=task,
attempt_index=attempt_index, attempt_index=attempt_index,
result=result, result=result,
team_result=team_result,
) )
evidence_text = render_task_evidence(evidence_packet) evidence_text = render_task_evidence(evidence_packet)
evidence_debug = { evidence_debug = {
@ -256,31 +189,6 @@ class TaskAttemptOrchestrator:
result.validation_result = None result.validation_result = None
return result return result
async def _run_team_for_task(
self,
plan: TaskExecutionPlan,
*,
task: TaskRecord,
parent_session_id: str,
provider_bundle_factory: Any,
) -> tuple[TeamRunResult | None, str | None]:
if plan.graph is None:
return None, "team plan did not include an execution graph"
try:
from beaver.services.team_service import TeamService
result = await TeamService(self.create_loop()).run_team(
plan.graph,
parent_task_id=task.task_id,
parent_session_id=parent_session_id,
parent_run_id=None,
provider_bundle_factory=provider_bundle_factory,
allow_candidate_generation=False,
)
return result, None
except Exception as exc:
return None, str(exc)
async def _assemble_task_attempt_skills( async def _assemble_task_attempt_skills(
self, self,
*, *,
@ -396,7 +304,6 @@ class TaskAttemptOrchestrator:
user_message: str, user_message: str,
attempt_index: int, attempt_index: int,
plan: TaskExecutionPlan | None = None, plan: TaskExecutionPlan | None = None,
team_summaries: list[str] | None = None,
) -> str: ) -> str:
phase = f"attempt_{attempt_index}" phase = f"attempt_{attempt_index}"
if task.feedback and task.feedback[-1].get("acceptance_type") == "revise": if task.feedback and task.feedback[-1].get("acceptance_type") == "revise":
@ -445,8 +352,6 @@ class TaskAttemptOrchestrator:
) )
) )
sections.append("Execution plan:\n" + "\n".join(plan_lines)) sections.append("Execution plan:\n" + "\n".join(plan_lines))
if team_summaries:
sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
sections.append( sections.append(
"Skill selection instruction:\n" "Skill selection instruction:\n"
"Prefer reusing previously activated skills when they still match the Task. " "Prefer reusing previously activated skills when they still match the Task. "
@ -476,140 +381,6 @@ class TaskAttemptOrchestrator:
def _join_context(*parts: str | None) -> str: def _join_context(*parts: str | None) -> str:
return "\n\n".join(part.strip() for part in parts if part and part.strip()) return "\n\n".join(part.strip() for part in parts if part and part.strip())
@staticmethod
def _team_summary_for_validation(result: TeamRunResult) -> str:
lines = [
f"success={result.success}",
f"task_id={result.task_id or ''}",
"summary:",
result.summary,
"nodes:",
]
for node in result.node_results:
lines.append(
f"- {node.node_id}: success={node.success} finish_reason={node.finish_reason} "
f"error={node.error or ''} output={node.output_text[:500]}"
)
return "\n".join(lines)
@staticmethod
def _team_node_results_for_event(plan: TaskExecutionPlan, result: TeamRunResult) -> list[dict[str, Any]]:
nodes = {node.node_id: node for node in plan.graph.nodes} if plan.graph else {}
payloads: list[dict[str, Any]] = []
for item in result.node_results:
payload = item.to_dict()
node = nodes.get(item.node_id)
if node is not None:
payload["selected_skill_names"] = list(node.inherited_pinned_skills)
payload["ephemeral_skill_names"] = [
skill.name for skill in node.inherited_pinned_skill_contexts
]
payload["skill_query"] = node.agent.metadata.get("skill_query")
payload["ephemeral_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
payloads.append(payload)
return payloads
@staticmethod
def _team_run_evidence(result: TeamRunResult | None) -> list[RunEvidence]:
if result is None:
return []
return [node.evidence for node in result.node_results if node.evidence is not None]
@staticmethod
def _team_synthesis_outcome(
plan: TaskExecutionPlan,
result: TeamRunResult | None,
*,
prompt_locale: str | None = None,
) -> tuple[str, str, dict[str, Any]]:
if not plan.is_team or plan.graph is None:
metadata = {
"task_outcome": "single",
"incomplete_node_ids": [],
"node_statuses": {},
"evidence_gaps": {},
}
return "Task outcome: single", "", metadata
result_by_node = {
item.node_id: item
for item in (result.node_results if result is not None else [])
}
node_statuses: dict[str, str] = {}
evidence_gaps: dict[str, list[str]] = {}
incomplete_node_ids: list[str] = []
detail_lines: list[str] = []
successful_lines: list[str] = []
for node in plan.graph.nodes:
node_result = result_by_node.get(node.node_id)
status = node_result.completion_status if node_result is not None else "not_run"
node_statuses[node.node_id] = status
gaps = list(node_result.evidence_gaps) if node_result is not None else []
if gaps:
evidence_gaps[node.node_id] = gaps
if node.required_for_completion and status != "succeeded":
incomplete_node_ids.append(node.node_id)
detail_lines.append(
f"- {node.node_id}: status={status}, "
f"finish_reason={node_result.finish_reason if node_result is not None else 'not_run'}, "
f"error={(node_result.error or '') if node_result is not None else 'node did not run'}, "
f"evidence_gaps={gaps}"
)
elif node_result is not None and status == "succeeded":
successful_lines.append(f"- {node.node_id}: {node_result.output_text[:1000]}")
task_outcome = "incomplete" if incomplete_node_ids else "complete"
metadata = {
"task_outcome": task_outcome,
"incomplete_node_ids": incomplete_node_ids,
"node_statuses": node_statuses,
"evidence_gaps": evidence_gaps,
}
context_parts = [
f"Task outcome: {task_outcome}",
"Incomplete node IDs: " + (", ".join(incomplete_node_ids) or "none"),
]
if detail_lines:
context_parts.append("Incomplete required node details:\n" + "\n".join(detail_lines))
if successful_lines:
context_parts.append("Available successful node evidence:\n" + "\n".join(successful_lines))
if task_outcome == "incomplete":
context_parts.append(
"Synthesis requirement: produce a partial report from available evidence and explicitly state "
"that the task is incomplete, partially completed, or missing required evidence."
)
prefix = TaskAttemptOrchestrator._incomplete_prefix(prompt_locale) if incomplete_node_ids else ""
return "\n\n".join(context_parts), prefix, metadata
@staticmethod
def _incomplete_prefix(prompt_locale: str | None) -> str:
locale = normalize_main_agent_prompt_locale(prompt_locale)
if locale == "en":
return "Task incomplete: some required steps failed or lack required evidence. The report below uses available results only.\n\n"
if locale == "zh-Hant":
return "任務未完成:部分必要步驟失敗或缺少必要證據。以下內容僅基於現有結果。\n\n"
return "任务未完成:部分必要步骤失败或缺少必要证据。以下内容仅基于现有结果。\n\n"
@staticmethod
def _apply_incomplete_prefix(output_text: str, prefix: str) -> str:
normalized = output_text.lower()
notices = (
"任务未完成",
"任務未完成",
"部分完成",
"缺少证据",
"缺少證據",
"task incomplete",
"incomplete task",
"partially complete",
"missing evidence",
)
if any(notice in normalized for notice in notices):
return output_text
return prefix + output_text.lstrip()
def _build_task_evidence_packet( def _build_task_evidence_packet(
self, self,
*, *,
@ -617,7 +388,6 @@ class TaskAttemptOrchestrator:
task: TaskRecord, task: TaskRecord,
attempt_index: int, attempt_index: int,
result: AgentRunResult, result: AgentRunResult,
team_result: TeamRunResult | None,
) -> TaskEvidencePacket: ) -> TaskEvidencePacket:
main_run = EvidenceBuilder(session_manager).build_run_evidence( main_run = EvidenceBuilder(session_manager).build_run_evidence(
result.session_id, result.session_id,
@ -629,67 +399,7 @@ class TaskAttemptOrchestrator:
task_id=task.task_id, task_id=task.task_id,
attempt_index=attempt_index, attempt_index=attempt_index,
main_run=main_run, main_run=main_run,
team_runs=self._team_run_evidence(team_result), team_runs=[],
team_node_results=list(team_result.node_results) if team_result is not None else [], team_node_results=[],
final_output=result.output_text, final_output=result.output_text,
) )
@staticmethod
def _team_execution_context(plan: TaskExecutionPlan, result: TeamRunResult) -> str:
node_lines = [
(
f"- {node.node_id}: success={node.success}, finish_reason={node.finish_reason}, "
f"run_id={node.run_id or ''}, error={node.error or ''}\n{node.output_text}"
)
for node in result.node_results
]
return "\n\n".join(
item
for item in [
"Task team execution result:",
f"Planner reason: {plan.reason}",
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
f"Team success: {result.success}",
f"Team summary:\n{result.summary}",
"Node results:\n" + "\n\n".join(node_lines),
(
"Final synthesis instruction:\n" + plan.final_synthesis_instruction
if plan.final_synthesis_instruction
else None
),
(
"Use successful team outputs as internal evidence. If one or more nodes failed, "
"do not blindly repeat failed tool calls. Produce a user-visible fallback answer "
"with available evidence and clearly state any missing or uncertain data."
),
]
if item
)
@staticmethod
def _failed_team_execution_context(plan: TaskExecutionPlan, error: str) -> str:
return "\n\n".join(
[
"Task team execution failed before final synthesis.",
f"Planner reason: {plan.reason}",
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
f"Error: {error}",
(
"Proceed as the main agent. Do not blindly repeat failed tool calls; "
"produce a user-visible fallback answer with available evidence and clearly "
"state any missing or uncertain data."
),
]
)
def _build_team_provider_bundle_factory(self, kwargs: dict[str, Any]) -> Any:
def factory(node: ExecutionNode) -> Any:
node_kwargs = dict(kwargs)
node_kwargs.pop("provider_bundle", None)
if node.agent.model:
node_kwargs["model"] = node.agent.model
if node.agent.provider_name:
node_kwargs["provider_name"] = node.agent.provider_name
return self.make_provider_bundle_for_task(self.loaded, node_kwargs)
return factory

View File

@ -155,7 +155,10 @@ def evaluate_node_evidence(
if not output_text.strip(): if not output_text.strip():
_append_unique(gaps, "missing required evidence: output") _append_unique(gaps, "missing required evidence: output")
else: else:
_append_unique(gaps, f"unsupported evidence requirement: {requirement}") # v1 only enforces the coarse machine-readable requirements above.
# Natural-language evidence requirements are preserved for later
# LLM-based validation and must not fail a node deterministically.
continue
return gaps return gaps

View File

@ -1,39 +1,27 @@
"""Internal Task execution planner for single-agent vs team execution.""" """Internal Task execution planner for single-agent task attempts.
Team execution is now started explicitly through local Team Workflow MCP tools.
This planner only records why the normal Task attempt should continue as a
single root-agent run.
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal from typing import Any, Literal
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode from beaver.coordinator.models import ExecutionGraph
from beaver.engine.context import SkillContext from beaver.engine.context import SkillContext
from beaver.engine.providers import ProviderBundle from beaver.engine.providers import ProviderBundle
from beaver.tools.registry import ToolRegistry
from .models import TaskRecord from .models import TaskRecord
from .skill_resolver import SkillResolutionReport, TaskSkillResolver from .skill_resolver import SkillResolutionReport
TaskExecutionMode = Literal["single", "team"] TaskExecutionMode = Literal["single", "team"]
# Temporary name-based denylist until high-risk tool approval is implemented.
# Keep this policy centralized so planner behavior cannot drift by call site.
HIGH_RISK_PLANNER_TOOL_NAMES = frozenset(
{
"delete_file",
"execute_command",
"external_send",
"send_email",
"terminal",
"write_file",
}
)
def _agent_team_enabled() -> bool: def _agent_team_enabled() -> bool:
return os.getenv("BEAVER_AGENT_TEAM_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"} return os.getenv("BEAVER_AGENT_TEAM_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
@ -96,37 +84,7 @@ class TaskExecutionPlan:
class TaskExecutionPlanner: class TaskExecutionPlanner:
"""Plan whether a Task attempt should run through a team first.""" """Return the current Task execution mode for the root AgentLoop."""
_MAX_NODES = 6
_MAX_DEPTH = 4
_SUPPORTED_STRATEGIES = {"sequence", "parallel", "dag"}
_ALLOWED_NODE_FIELDS = {
"node_id",
"task",
"use_skill",
"skill_query",
"depends_on",
"input_contract",
"output_contract",
"requested_tools",
"required_evidence",
"evidence_contract",
"validation_rules",
"required_for_completion",
"block_downstream_on_partial",
"max_tool_iterations",
"constraints",
}
def __init__(
self,
*,
task_skill_resolver: TaskSkillResolver | None = None,
tool_registry: ToolRegistry | None = None,
) -> None:
self.task_skill_resolver = task_skill_resolver
self.tool_registry = tool_registry
async def plan( async def plan(
self, self,
@ -144,122 +102,7 @@ class TaskExecutionPlanner:
return TaskExecutionPlan.single("planner_disabled_by_environment") return TaskExecutionPlan.single("planner_disabled_by_environment")
if not self._needs_team_planning(task=task, user_message=user_message): if not self._needs_team_planning(task=task, user_message=user_message):
return TaskExecutionPlan.single("planner_skipped_simple_task") return TaskExecutionPlan.single("planner_skipped_simple_task")
return TaskExecutionPlan.single("planner_team_replaced_by_workflow_tools")
provider = None
model = None
if provider_bundle is not None:
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
model = getattr(runtime, "model", None)
if provider is None:
return TaskExecutionPlan.single("planner_provider_unavailable")
selected_template, base_adaptation = self._select_team_template(activated_skills or [])
try:
response = await asyncio.wait_for(
provider.chat(
messages=[
{
"role": "system",
"content": (
"You choose whether an internal Beaver Task attempt should run as a single "
"main-agent pass or use a small sub-agent team first. Return only compact JSON."
),
},
{
"role": "user",
"content": self._prompt(
task=task,
user_message=user_message,
attempt_index=attempt_index,
skill_summaries=skill_summaries or [],
tool_hints=tool_hints or [],
activated_skills=activated_skills or [],
selected_template=selected_template,
),
},
],
tools=None,
model=model,
max_tokens=4096,
temperature=0.0,
),
timeout=timeout_seconds,
)
try:
plan = self._from_json_or_raise(response.content or "")
except Exception as first_error:
repair_response = await asyncio.wait_for(
provider.chat(
messages=[
{
"role": "system",
"content": "Repair invalid Beaver task planner JSON. Return only one compact JSON object.",
},
{
"role": "user",
"content": (
"Repair the invalid planner JSON using the task-only schema from the original "
f"request. Validation error: {first_error}\nInvalid output:\n{response.content or ''}"
),
},
],
tools=None,
model=model,
max_tokens=4096,
temperature=0.0,
),
timeout=timeout_seconds,
)
try:
plan = self._from_json_or_raise(repair_response.content or "")
except Exception as repair_error:
return TaskExecutionPlan.single(
"planner_fallback_single",
fallback_error=f"initial validation: {first_error}; repair validation: {repair_error}",
planner_adaptation=base_adaptation,
)
self._merge_adaptation(plan, base_adaptation)
return await self._resolve_plan(
plan,
task=task,
user_message=user_message,
attempt_index=attempt_index,
provider_bundle=provider_bundle,
)
except Exception as exc:
detail = str(exc)
error = f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
return TaskExecutionPlan.single("planner_failed", fallback_error=error)
async def _resolve_plan(
self,
plan: TaskExecutionPlan,
*,
task: TaskRecord,
user_message: str,
attempt_index: int,
provider_bundle: ProviderBundle | None,
) -> TaskExecutionPlan:
if not plan.is_team or self.task_skill_resolver is None:
return plan
if provider_bundle is None:
return TaskExecutionPlan.single("planner_fallback_single", fallback_error="task_skill_resolver_provider_unavailable")
try:
assert plan.graph is not None
graph, reports = await self.task_skill_resolver.resolve_graph(
plan.graph,
task=task,
user_message=user_message,
attempt_index=attempt_index,
provider_bundle=provider_bundle,
)
graph.validate()
plan.graph = graph
plan.skill_resolution_report = reports
self._merge_skill_resolution_adaptation(plan, reports)
return plan
except Exception as exc:
return TaskExecutionPlan.single("planner_fallback_single", fallback_error=f"task_skill_resolver_failed: {exc}")
@staticmethod @staticmethod
def _needs_team_planning(*, task: TaskRecord, user_message: str) -> bool: def _needs_team_planning(*, task: TaskRecord, user_message: str) -> bool:
@ -306,307 +149,3 @@ class TaskExecutionPlanner:
"端到端", "端到端",
) )
return any(marker in text for marker in complex_markers) return any(marker in text for marker in complex_markers)
def from_json(self, text: str) -> TaskExecutionPlan:
try:
return self._from_json_or_raise(text)
except Exception as exc:
return TaskExecutionPlan.single("planner_fallback_single", fallback_error=str(exc))
def _from_json_or_raise(self, text: str) -> TaskExecutionPlan:
payload = self._parse_json_object(text)
mode = str(payload.get("mode") or "single").strip().lower()
reason = str(payload.get("reason") or "")
adaptation = self._adaptation_from_payload(payload)
if mode != "team":
return TaskExecutionPlan.single(
reason or "planner_selected_single",
planner_adaptation=adaptation,
)
graph = self._graph_from_payload(payload, adaptation=adaptation)
graph.validate(max_depth=self._MAX_DEPTH)
return TaskExecutionPlan(
mode="team",
reason=reason or "planner_selected_team",
graph=graph,
final_synthesis_instruction=str(payload.get("final_synthesis_instruction") or ""),
planner_adaptation=adaptation,
)
def _graph_from_payload(
self,
payload: dict[str, Any],
*,
adaptation: dict[str, Any],
) -> ExecutionGraph:
strategy = str(payload.get("strategy") or "sequence").strip().lower()
if strategy not in self._SUPPORTED_STRATEGIES:
raise ValueError(f"Unsupported team strategy: {strategy}")
raw_nodes = payload.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise ValueError("Team plan requires at least one node")
if len(raw_nodes) > self._MAX_NODES:
raise ValueError(f"Team plan exceeds max node count {self._MAX_NODES}")
nodes: list[ExecutionNode] = []
for index, item in enumerate(raw_nodes, start=1):
if not isinstance(item, dict):
raise ValueError("Each team node must be an object")
unsupported = sorted(set(item) - self._ALLOWED_NODE_FIELDS)
if unsupported:
raise ValueError(f"Unsupported team node field(s): {', '.join(unsupported)}")
node_id = str(item.get("node_id") or f"node_{index}").strip()
task = str(item.get("task") or "").strip()
if not node_id or not task:
raise ValueError("Each team node requires node_id and task")
allowed_tool_names = self._resolve_requested_tools(
item.get("requested_tools"),
warnings=adaptation["warnings"],
)
use_skill = _optional_str(item.get("use_skill"))
skill_query = _optional_str(item.get("skill_query")) or task
if use_skill is not None or "skill_query" in item:
adaptation.setdefault("node_skill_bindings", []).append(
{
"node_id": node_id,
"use_skill": use_skill,
"skill_query": skill_query,
}
)
nodes.append(
ExecutionNode(
node_id=node_id,
task=task,
agent=AgentDescriptor(
name=node_id,
role="",
system_prompt="",
metadata={
"use_skill": use_skill,
"skill_query": skill_query,
"required_capabilities": [],
"requested_tags": [],
"sub_agent_kind": "generic_skill_worker",
},
),
depends_on=[str(dep) for dep in item.get("depends_on") or []],
constraints=[str(value) for value in item.get("constraints") or []],
input_contract=_dict_value(item.get("input_contract")),
output_contract=_dict_value(item.get("output_contract")),
allowed_tool_names=allowed_tool_names,
required_evidence=_string_list(item.get("required_evidence")),
evidence_contract=_dict_value(item.get("evidence_contract")),
validation_rules=_string_list(item.get("validation_rules")),
required_for_completion=bool(item.get("required_for_completion", True)),
block_downstream_on_partial=bool(item.get("block_downstream_on_partial", False)),
max_tool_iterations=_optional_int(item.get("max_tool_iterations")),
)
)
return ExecutionGraph(strategy=strategy, nodes=nodes) # type: ignore[arg-type]
def _resolve_requested_tools(self, value: Any, *, warnings: list[str]) -> list[str] | None:
if value is None:
return None
result: list[str] = []
for name in _string_list(value):
if name.lower() in HIGH_RISK_PLANNER_TOOL_NAMES:
_append_unique(warnings, f"requires_high_risk_review: {name}")
continue
if self.tool_registry is None or self.tool_registry.get(name) is None:
_append_unique(warnings, f"unknown tool removed: {name}")
continue
result.append(name)
return result
@staticmethod
def _adaptation_from_payload(payload: dict[str, Any]) -> dict[str, Any]:
raw = payload.get("adaptation")
adaptation = dict(raw) if isinstance(raw, dict) else {}
adaptation["warnings"] = _string_list(adaptation.get("warnings"))
return adaptation
@staticmethod
def _select_team_template(
activated_skills: list[SkillContext],
) -> tuple[SkillContext | None, dict[str, Any]]:
candidates = [
skill
for skill in activated_skills
if isinstance(skill.team_template, dict) and isinstance(skill.team_template.get("nodes"), list)
]
selected = candidates[0] if candidates else None
warnings: list[str] = []
for skill in activated_skills:
for warning in skill.team_template_warnings:
_append_unique(warnings, f"{skill.name}: {warning}")
return selected, {
"template_used": False,
"selected_template": selected.name if selected else None,
"selection_reason": (
"first activated skill with a valid team template"
if selected
else "no activated skill has a valid team template"
),
"ignored_templates": [skill.name for skill in candidates[1:]],
"warnings": warnings,
}
@staticmethod
def _merge_adaptation(plan: TaskExecutionPlan, base: dict[str, Any]) -> None:
payload = dict(plan.planner_adaptation)
warnings: list[str] = []
for warning in [*base.get("warnings", []), *payload.get("warnings", [])]:
_append_unique(warnings, str(warning))
merged = {
"template_used": bool(payload.get("template_used", False)),
"selected_template": base.get("selected_template"),
"selection_reason": base.get("selection_reason"),
"ignored_templates": list(base.get("ignored_templates", [])),
"warnings": warnings,
}
if isinstance(payload.get("node_skill_bindings"), list):
merged["node_skill_bindings"] = [dict(item) for item in payload["node_skill_bindings"] if isinstance(item, dict)]
plan.planner_adaptation = merged
@staticmethod
def _merge_skill_resolution_adaptation(
plan: TaskExecutionPlan,
reports: list[SkillResolutionReport],
) -> None:
warnings = plan.planner_adaptation.setdefault("warnings", [])
bindings = plan.planner_adaptation.get("node_skill_bindings")
binding_by_node = {
str(item.get("node_id")): item
for item in bindings or []
if isinstance(item, dict)
}
for report in reports:
for warning in report.warnings:
_append_unique(warnings, warning)
binding = binding_by_node.get(report.node_id)
if binding is not None and report.requested_skill_name and not report.exact_binding_used:
binding["fallback_reason"] = f"use_skill unresolved; {report.reason}"
@staticmethod
def _prompt(
*,
task: TaskRecord,
user_message: str,
attempt_index: int,
skill_summaries: list[str] | None = None,
tool_hints: list[str] | None = None,
activated_skills: list[SkillContext] | None = None,
selected_template: SkillContext | None = None,
) -> str:
history_note = ""
if task.feedback:
history_note = "\nRelevant task history:\n" + json.dumps(task.feedback[-5:], ensure_ascii=False)
skill_note = ""
if skill_summaries:
skill_note = "\nActivated skill summaries:\n" + "\n".join(f"- {item}" for item in skill_summaries)
guidance_note = ""
if activated_skills:
guidance_note = "\nActivated Skill guidance:\n" + "\n".join(
f"[{skill.name}]\n{skill.content}" for skill in activated_skills
)
template_note = ""
if selected_template is not None:
template_note = "\nPrimary Skill team template:\n" + json.dumps(
{
"skill_name": selected_template.name,
"skill_version": selected_template.version,
"template": selected_template.team_template,
},
ensure_ascii=False,
indent=2,
)
tool_note = ""
if tool_hints:
tool_note = "\nActivated skill tool hints:\n" + "\n".join(f"- {item}" for item in tool_hints)
return (
"Decide execution mode for this internal Task attempt.\n"
"Use mode=team only when independent research, review, implementation slices, or staged checks "
"would materially improve the result. Otherwise use mode=single.\n\n"
"JSON schema:\n"
"{\n"
' "mode": "single" | "team",\n'
' "reason": "short reason",\n'
' "strategy": "sequence" | "parallel" | "dag",\n'
' "nodes": [{"node_id": "collect", "task": "...", "use_skill": "optional exact skill", '
'"skill_query": "optional dynamic skill query", "depends_on": [], '
'"input_contract": {}, "output_contract": {}, "requested_tools": [], '
'"required_evidence": [], "evidence_contract": {}, "validation_rules": [], '
'"required_for_completion": true, "block_downstream_on_partial": false, '
'"max_tool_iterations": 3, "constraints": []}],\n'
' "adaptation": {"template_used": true, "warnings": []},\n'
' "final_synthesis_instruction": "how the main agent should synthesize team output"\n'
"}\n\n"
"Node definitions are task-only. Never output agent or role fields. Use at most one primary "
"Skill template; treat all other activated Skills as guidance.\n\n"
f"Task goal:\n{task.goal}\n\n"
f"Current user request:\n{user_message}\n\n"
f"Attempt index: {attempt_index}\n"
f"{skill_note}"
f"{guidance_note}"
f"{template_note}"
f"{tool_note}"
f"{history_note}"
)
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:].strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start >= 0 and end >= start:
cleaned = cleaned[start : end + 1]
payload = json.loads(cleaned)
if not isinstance(payload, dict):
raise ValueError("planner response must be a JSON object")
return payload
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
text = str(value).strip()
return text or None
def _optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
if isinstance(value, bool):
raise ValueError("max_tool_iterations must be an integer")
result = int(value)
if result < 0:
raise ValueError("max_tool_iterations must be non-negative")
return result
def _dict_value(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _append_unique(values: list[str], value: str) -> None:
if value and value not in values:
values.append(value)
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
if isinstance(value, str):
value = [item.strip() for item in value.split(",")]
else:
return []
result: list[str] = []
for item in value:
text = str(item).strip()
if text and text not in result:
result.append(text)
return result

View File

@ -0,0 +1,2 @@
"""Local team workflow graph builders."""

View File

@ -0,0 +1,70 @@
"""AgentRearrange graph builder using arrow/comma flow syntax."""
from __future__ import annotations
from typing import Any, Iterable
from beaver.coordinator.models import ExecutionGraph
from .base import (
WorkflowAgentSpec,
agent_name_set,
build_graph_from_dependencies,
edges_to_dependencies,
parse_agents,
validate_no_disconnected_agents,
)
WORKFLOW_NAME = "AgentRearrange"
def build_graph(
*,
task: str,
agents: Iterable[WorkflowAgentSpec | dict[str, Any]],
flow: str,
) -> ExecutionGraph:
del task
parsed = parse_agents(agents)
edges = parse_flow(flow, known_agents=agent_name_set(parsed))
dependencies = edges_to_dependencies(agents=parsed, edges=edges)
validate_no_disconnected_agents(agents=parsed, dependencies=dependencies)
return build_graph_from_dependencies(
workflow_name=WORKFLOW_NAME,
strategy="dag",
agents=parsed,
dependencies=dependencies,
)
def parse_flow(flow: str, *, known_agents: set[str]) -> list[tuple[str, str]]:
stages = _parse_stages(flow)
edges: list[tuple[str, str]] = []
for stage in stages:
for name in stage:
if name not in known_agents:
raise ValueError(f"workflow flow references unknown agent: {name}")
for left, right in zip(stages, stages[1:], strict=False):
for source in left:
for target in right:
edge = (source, target)
if edge not in edges:
edges.append(edge)
return edges
def _parse_stages(flow: str) -> list[list[str]]:
raw_flow = str(flow or "").strip()
if not raw_flow:
raise ValueError("workflow flow is required")
stages: list[list[str]] = []
for raw_stage in raw_flow.split("->"):
names = [name.strip() for name in raw_stage.split(",") if name.strip()]
if not names:
raise ValueError("workflow flow contains an empty stage")
if len(names) != len(set(names)):
raise ValueError("workflow flow contains duplicate agent names in a stage")
stages.append(names)
if len(stages) < 2:
raise ValueError("workflow flow must contain at least two stages")
return stages

View File

@ -0,0 +1,273 @@
"""Shared builders for local team workflow graph construction."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Literal
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
GraphStrategy = Literal["sequence", "parallel", "dag"]
@dataclass(slots=True)
class WorkflowAgentSpec:
name: str
instruction: str
use_skill: str | None = None
skill_query: str | None = None
allowed_tool_names: list[str] | None = None
required_evidence: list[str] = field(default_factory=list)
evidence_contract: dict[str, Any] = field(default_factory=dict)
validation_rules: list[str] = field(default_factory=list)
required_for_completion: bool = True
block_downstream_on_partial: bool = False
max_tool_iterations: int | None = None
constraints: list[str] = field(default_factory=list)
expected_output: str | None = None
input_contract: dict[str, Any] = field(default_factory=dict)
output_contract: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class WorkflowBuildResult:
graph: ExecutionGraph
workflow_name: str
def parse_agents(raw_agents: Iterable[WorkflowAgentSpec | dict[str, Any]]) -> list[WorkflowAgentSpec]:
agents: list[WorkflowAgentSpec] = []
for index, raw in enumerate(raw_agents, start=1):
if isinstance(raw, WorkflowAgentSpec):
spec = raw
elif isinstance(raw, dict):
spec = _agent_from_dict(raw, index=index)
else:
raise ValueError("workflow agents must be objects")
agents.append(spec)
validate_agent_names(agents)
return agents
def validate_agent_names(agents: list[WorkflowAgentSpec]) -> None:
if not agents:
raise ValueError("workflow requires at least one agent")
seen: set[str] = set()
for agent in agents:
if not agent.name:
raise ValueError("workflow agent name is required")
if not agent.instruction:
raise ValueError(f"workflow agent {agent.name!r} requires instruction")
if agent.name in seen:
raise ValueError(f"workflow agent names must be unique: {agent.name}")
seen.add(agent.name)
def agent_name_set(agents: list[WorkflowAgentSpec]) -> set[str]:
return {agent.name for agent in agents}
def build_graph_from_dependencies(
*,
workflow_name: str,
strategy: GraphStrategy,
agents: list[WorkflowAgentSpec],
dependencies: dict[str, list[str]],
) -> ExecutionGraph:
nodes = [
build_node(
workflow_name=workflow_name,
agent=agent,
depends_on=dependencies.get(agent.name, []),
)
for agent in agents
]
graph = ExecutionGraph(strategy=strategy, nodes=nodes)
graph.validate()
return graph
def build_node(
*,
workflow_name: str,
agent: WorkflowAgentSpec,
depends_on: list[str],
) -> ExecutionNode:
metadata = {
"sub_agent_kind": "generic_skill_worker",
"workflow_tool": workflow_name,
"workflow_agent_name": agent.name,
}
if agent.use_skill:
metadata["use_skill"] = agent.use_skill
if agent.skill_query:
metadata["skill_query"] = agent.skill_query
return ExecutionNode(
node_id=agent.name,
task=agent.instruction,
agent=AgentDescriptor(
name=agent.name,
role="",
system_prompt="",
metadata=metadata,
),
depends_on=list(depends_on),
constraints=list(agent.constraints),
expected_output=agent.expected_output,
input_contract=dict(agent.input_contract),
output_contract=dict(agent.output_contract),
allowed_tool_names=(
None if agent.allowed_tool_names is None else list(agent.allowed_tool_names)
),
required_evidence=list(agent.required_evidence),
evidence_contract=dict(agent.evidence_contract),
validation_rules=list(agent.validation_rules),
required_for_completion=agent.required_for_completion,
block_downstream_on_partial=agent.block_downstream_on_partial,
max_tool_iterations=agent.max_tool_iterations,
)
def edges_to_dependencies(
*,
agents: list[WorkflowAgentSpec],
edges: Iterable[tuple[str, str] | list[str]],
) -> dict[str, list[str]]:
known = agent_name_set(agents)
dependencies = {agent.name: [] for agent in agents}
for raw_edge in edges:
source, target = _parse_edge(raw_edge)
if source not in known:
raise ValueError(f"workflow edge references unknown agent: {source}")
if target not in known:
raise ValueError(f"workflow edge references unknown agent: {target}")
if source == target:
raise ValueError(f"workflow edge creates a self-cycle: {source}")
if source not in dependencies[target]:
dependencies[target].append(source)
return dependencies
def validate_output_agent(
*,
agents: list[WorkflowAgentSpec],
dependencies: dict[str, list[str]],
output_agent: str,
allow_disconnected: bool = False,
) -> None:
known = agent_name_set(agents)
if output_agent not in known:
raise ValueError(f"workflow output_agent references unknown agent: {output_agent}")
upstream = _upstream_nodes(output_agent, dependencies)
if not upstream:
raise ValueError(f"workflow output_agent {output_agent!r} must be reachable from upstream agents")
if allow_disconnected:
return
connected = set(upstream)
connected.add(output_agent)
disconnected = sorted(known - connected)
if disconnected:
raise ValueError(f"workflow has disconnected agent(s): {', '.join(disconnected)}")
def validate_no_disconnected_agents(
*,
agents: list[WorkflowAgentSpec],
dependencies: dict[str, list[str]],
) -> None:
known = agent_name_set(agents)
connected: set[str] = set()
for target, sources in dependencies.items():
if sources:
connected.add(target)
connected.update(sources)
disconnected = sorted(known - connected)
if disconnected:
raise ValueError(f"workflow has disconnected agent(s): {', '.join(disconnected)}")
def _agent_from_dict(raw: dict[str, Any], *, index: int) -> WorkflowAgentSpec:
name = _required_str(raw.get("name"), f"agents[{index}].name")
instruction = _required_str(raw.get("instruction"), f"agents[{index}].instruction")
return WorkflowAgentSpec(
name=name,
instruction=instruction,
use_skill=_optional_str(raw.get("use_skill")),
skill_query=_optional_str(raw.get("skill_query")),
allowed_tool_names=_optional_string_list(raw.get("allowed_tool_names")),
required_evidence=_string_list(raw.get("required_evidence")),
evidence_contract=_dict(raw.get("evidence_contract")),
validation_rules=_string_list(raw.get("validation_rules")),
required_for_completion=bool(raw.get("required_for_completion", True)),
block_downstream_on_partial=bool(raw.get("block_downstream_on_partial", False)),
max_tool_iterations=_optional_int(raw.get("max_tool_iterations")),
constraints=_string_list(raw.get("constraints")),
expected_output=_optional_str(raw.get("expected_output")),
input_contract=_dict(raw.get("input_contract")),
output_contract=_dict(raw.get("output_contract")),
)
def _parse_edge(raw_edge: tuple[str, str] | list[str]) -> tuple[str, str]:
if not isinstance(raw_edge, (list, tuple)) or len(raw_edge) != 2:
raise ValueError("workflow edges must be [source, target] pairs")
source = _required_str(raw_edge[0], "edge source")
target = _required_str(raw_edge[1], "edge target")
return source, target
def _upstream_nodes(node_id: str, dependencies: dict[str, list[str]]) -> set[str]:
result: set[str] = set()
def visit(current: str) -> None:
for dependency in dependencies.get(current, []):
if dependency in result:
continue
result.add(dependency)
visit(dependency)
visit(node_id)
return result
def _required_str(value: Any, label: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{label} is required")
return text
def _optional_str(value: Any) -> str | None:
text = str(value or "").strip()
return text or None
def _string_list(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("expected a list of strings")
return [str(item).strip() for item in value if str(item).strip()]
def _optional_string_list(value: Any) -> list[str] | None:
if value is None:
return None
return _string_list(value)
def _dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _optional_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError("max_tool_iterations must be an integer") from exc

View File

@ -0,0 +1,26 @@
"""ConcurrentWorkflow graph builder."""
from __future__ import annotations
from typing import Any, Iterable
from beaver.coordinator.models import ExecutionGraph
from .base import WorkflowAgentSpec, build_graph_from_dependencies, parse_agents
WORKFLOW_NAME = "ConcurrentWorkflow"
def build_graph(
*,
task: str,
agents: Iterable[WorkflowAgentSpec | dict[str, Any]],
) -> ExecutionGraph:
del task
parsed = parse_agents(agents)
return build_graph_from_dependencies(
workflow_name=WORKFLOW_NAME,
strategy="parallel",
agents=parsed,
dependencies={agent.name: [] for agent in parsed},
)

View File

@ -0,0 +1,174 @@
"""Runtime bridge for local team workflow MCP tools."""
from __future__ import annotations
import json
from typing import Any, Callable
from beaver.coordinator.models import ExecutionGraph, TeamRunResult
from beaver.tools.base import ToolContext, ToolResult
from . import agent_rearrange, concurrent, graph, mixture_of_agents, sequential
GraphBuilder = Callable[..., ExecutionGraph]
class TeamWorkflowExecutor:
"""Execute workflow MCP calls inside the current Beaver runtime."""
_BUILDERS: dict[str, GraphBuilder] = {
"SequentialWorkflow": sequential.build_graph,
"ConcurrentWorkflow": concurrent.build_graph,
"MixtureOfAgents": mixture_of_agents.build_graph,
"AgentRearrange": agent_rearrange.build_graph,
"GraphWorkflow": graph.build_graph,
}
async def execute(
self,
workflow_name: str,
arguments: dict[str, Any],
context: ToolContext,
*,
tool_name: str | None = None,
) -> ToolResult:
exposed_name = tool_name or workflow_name
try:
if str(context.metadata.get("source") or "").startswith("team:"):
raise ValueError("nested_team_workflow_not_allowed")
builder = self._BUILDERS.get(workflow_name)
if builder is None:
raise ValueError(f"unknown team workflow tool: {workflow_name}")
graph = builder(**dict(arguments or {}))
parent_task_id = _task_id(context)
parent_session_id = _session_id(context)
result = await self._run_team(
context=context,
graph=graph,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
)
payload = _success_payload(
workflow_name=workflow_name,
graph=graph,
result=result,
)
return ToolResult(
success=True,
content=json.dumps(payload, ensure_ascii=False),
tool_name=exposed_name,
raw_output=payload,
)
except Exception as exc:
payload = {
"success": False,
"workflow": workflow_name,
"error": str(exc),
}
return ToolResult(
success=False,
content=json.dumps(payload, ensure_ascii=False),
tool_name=exposed_name,
error=str(exc),
raw_output=payload,
)
async def _run_team(
self,
*,
context: ToolContext,
graph: ExecutionGraph,
parent_task_id: str,
parent_session_id: str,
) -> TeamRunResult:
runner = context.services.get("agent_team_runner")
parent_run_id = _run_id(context)
if runner is not None:
return await runner(
graph,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
)
agent_loop = context.services.get("agent_loop")
if agent_loop is None:
raise ValueError("team workflow execution requires agent_loop or agent_team_runner")
provider_bundle = context.services.get("provider_bundle")
def provider_bundle_factory(_node: Any) -> Any:
return provider_bundle
from beaver.engine import AgentLoop
from beaver.services.team_service import TeamService
loaded = context.services.get("loaded")
team_loop = AgentLoop(profile=agent_loop.profile, loader=agent_loop.loader)
team_loop.loaded = loaded
return await TeamService(team_loop).run_team(
graph,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle_factory=provider_bundle_factory if provider_bundle is not None else None,
allow_candidate_generation=False,
)
def _task_id(context: ToolContext) -> str:
value = str(context.services.get("task_id") or context.metadata.get("task_id") or "").strip()
if not value:
raise ValueError("team workflow execution requires task_id")
return value
def _session_id(context: ToolContext) -> str:
value = str(context.session_id or context.services.get("session_id") or "").strip()
if not value:
raise ValueError("team workflow execution requires session_id")
return value
def _run_id(context: ToolContext) -> str | None:
return str(context.services.get("run_id") or context.metadata.get("run_id") or "").strip() or None
def _success_payload(
*,
workflow_name: str,
graph: ExecutionGraph,
result: TeamRunResult,
) -> dict[str, Any]:
return {
"success": result.success,
"workflow": workflow_name,
"summary": result.summary,
"run_ids": list(result.run_ids),
"session_ids": list(result.session_ids),
"node_results": [item.to_dict() for item in result.node_results],
"graph": _graph_to_dict(graph),
}
def _graph_to_dict(graph: ExecutionGraph) -> dict[str, Any]:
return {
"strategy": graph.strategy,
"nodes": [
{
"node_id": node.node_id,
"task": node.task,
"depends_on": list(node.depends_on),
"allowed_tool_names": (
None if node.allowed_tool_names is None else list(node.allowed_tool_names)
),
"required_evidence": list(node.required_evidence),
"evidence_contract": dict(node.evidence_contract),
"validation_rules": list(node.validation_rules),
"required_for_completion": node.required_for_completion,
"block_downstream_on_partial": node.block_downstream_on_partial,
"max_tool_iterations": node.max_tool_iterations,
"metadata": dict(node.agent.metadata),
}
for node in graph.nodes
],
}

View File

@ -0,0 +1,45 @@
"""GraphWorkflow explicit DAG builder."""
from __future__ import annotations
from typing import Any, Iterable
from beaver.coordinator.models import ExecutionGraph
from .base import (
WorkflowAgentSpec,
build_graph_from_dependencies,
edges_to_dependencies,
parse_agents,
validate_output_agent,
)
WORKFLOW_NAME = "GraphWorkflow"
def build_graph(
*,
task: str,
agents: Iterable[WorkflowAgentSpec | dict[str, Any]],
edges: Iterable[tuple[str, str] | list[str]],
output_agent: str,
allow_disconnected: bool = False,
) -> ExecutionGraph:
del task
parsed = parse_agents(agents)
edge_list = list(edges or [])
if not edge_list:
raise ValueError("GraphWorkflow requires edges")
dependencies = edges_to_dependencies(agents=parsed, edges=edge_list)
validate_output_agent(
agents=parsed,
dependencies=dependencies,
output_agent=str(output_agent or "").strip(),
allow_disconnected=allow_disconnected,
)
return build_graph_from_dependencies(
workflow_name=WORKFLOW_NAME,
strategy="dag",
agents=parsed,
dependencies=dependencies,
)

View File

@ -0,0 +1,261 @@
"""MCP schema tools for local team workflow graph builders."""
from __future__ import annotations
import json
from typing import Any, Callable
from beaver.coordinator.models import ExecutionGraph
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
from . import agent_rearrange, concurrent, graph, mixture_of_agents, sequential
GraphBuilder = Callable[..., ExecutionGraph]
def create_team_workflow_tools() -> list[BaseTool]:
return [
TeamWorkflowSchemaTool(
name="SequentialWorkflow",
description=(
"Build a sequential Beaver team workflow graph. Use this for staged work "
"where each agent depends on the previous agent's output."
),
input_schema=_sequential_schema(),
builder=sequential.build_graph,
),
TeamWorkflowSchemaTool(
name="ConcurrentWorkflow",
description=(
"Build a concurrent Beaver team workflow graph. Use this only when agents "
"can work independently on the same task."
),
input_schema=_concurrent_schema(),
builder=concurrent.build_graph,
),
TeamWorkflowSchemaTool(
name="MixtureOfAgents",
description=(
"Build a mixture-of-agents Beaver team workflow graph where independent "
"expert agents feed one aggregator agent."
),
input_schema=_mixture_schema(),
builder=mixture_of_agents.build_graph,
),
TeamWorkflowSchemaTool(
name="AgentRearrange",
description=(
"Build a Beaver team workflow graph from strict flow syntax. Use '->' for "
"stage order and ',' for agents in the same parallel stage."
),
input_schema=_agent_rearrange_schema(),
builder=agent_rearrange.build_graph,
),
TeamWorkflowSchemaTool(
name="GraphWorkflow",
description=(
"Build an explicit Beaver DAG workflow graph. Use this advanced tool only "
"when the dependency edges must be specified directly."
),
input_schema=_graph_schema(),
builder=graph.build_graph,
),
]
class TeamWorkflowSchemaTool(BaseTool):
def __init__(
self,
*,
name: str,
description: str,
input_schema: dict[str, Any],
builder: GraphBuilder,
) -> None:
self._spec = ToolSpec(
name=name,
description=description,
input_schema=input_schema,
toolset="team_workflow",
always_available=False,
metadata={"category": "team_workflow"},
)
self._builder = builder
@property
def spec(self) -> ToolSpec:
return self._spec
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
del context
try:
graph = self._builder(**dict(arguments or {}))
payload = {
"success": True,
"workflow": self.spec.name,
"graph": _graph_to_dict(graph),
}
return ToolResult(
success=True,
content=json.dumps(payload, ensure_ascii=False),
tool_name=self.spec.name,
raw_output=payload,
)
except Exception as exc:
payload = {"success": False, "workflow": self.spec.name, "error": str(exc)}
return ToolResult(
success=False,
content=json.dumps(payload, ensure_ascii=False),
tool_name=self.spec.name,
error=str(exc),
raw_output=payload,
)
def _graph_to_dict(graph: ExecutionGraph) -> dict[str, Any]:
return {
"strategy": graph.strategy,
"nodes": [
{
"node_id": node.node_id,
"task": node.task,
"depends_on": list(node.depends_on),
"allowed_tool_names": (
None if node.allowed_tool_names is None else list(node.allowed_tool_names)
),
"required_evidence": list(node.required_evidence),
"evidence_contract": dict(node.evidence_contract),
"validation_rules": list(node.validation_rules),
"required_for_completion": node.required_for_completion,
"block_downstream_on_partial": node.block_downstream_on_partial,
"max_tool_iterations": node.max_tool_iterations,
"metadata": dict(node.agent.metadata),
}
for node in graph.nodes
],
}
def _sequential_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": _task_schema(),
"agents": _agents_schema(),
},
"required": ["task", "agents"],
"additionalProperties": False,
}
def _concurrent_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": _task_schema(),
"agents": _agents_schema(),
},
"required": ["task", "agents"],
"additionalProperties": False,
}
def _mixture_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": _task_schema(),
"agents": _agents_schema(description="Expert agents that run independently before aggregation."),
"aggregator": _agent_schema(description="Aggregator agent that synthesizes expert outputs."),
},
"required": ["task", "agents", "aggregator"],
"additionalProperties": False,
}
def _agent_rearrange_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": _task_schema(),
"agents": _agents_schema(),
"flow": {
"type": "string",
"description": "Strict flow syntax, e.g. 'collector -> tactics, players -> synthesizer'.",
},
},
"required": ["task", "agents", "flow"],
"additionalProperties": False,
}
def _graph_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": _task_schema(),
"agents": _agents_schema(),
"edges": {
"type": "array",
"description": "Directed dependency edges as [source_agent, target_agent] pairs.",
"items": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {"type": "string"},
},
},
"output_agent": {
"type": "string",
"description": "Final output/synthesis agent. Must be reachable from upstream agents.",
},
"allow_disconnected": {
"type": "boolean",
"description": "Allow agents that are not connected to output_agent. Defaults to false.",
},
},
"required": ["task", "agents", "edges", "output_agent"],
"additionalProperties": False,
}
def _task_schema() -> dict[str, Any]:
return {
"type": "string",
"description": "Overall user task this workflow supports.",
}
def _agents_schema(*, description: str = "Workflow agents in the order or set used by this workflow.") -> dict[str, Any]:
return {
"type": "array",
"description": description,
"items": _agent_schema(),
"minItems": 1,
}
def _agent_schema(*, description: str = "One workflow agent slot.") -> dict[str, Any]:
return {
"type": "object",
"description": description,
"properties": {
"name": {"type": "string"},
"instruction": {"type": "string"},
"use_skill": {"type": "string"},
"skill_query": {"type": "string"},
"allowed_tool_names": {"type": "array", "items": {"type": "string"}},
"required_evidence": {"type": "array", "items": {"type": "string"}},
"evidence_contract": {"type": "object"},
"validation_rules": {"type": "array", "items": {"type": "string"}},
"required_for_completion": {"type": "boolean"},
"block_downstream_on_partial": {"type": "boolean"},
"max_tool_iterations": {"type": "integer"},
"constraints": {"type": "array", "items": {"type": "string"}},
"expected_output": {"type": "string"},
"input_contract": {"type": "object"},
"output_contract": {"type": "object"},
},
"required": ["name", "instruction"],
"additionalProperties": False,
}

View File

@ -0,0 +1,37 @@
"""MixtureOfAgents graph builder."""
from __future__ import annotations
from typing import Any, Iterable
from beaver.coordinator.models import ExecutionGraph
from .base import (
WorkflowAgentSpec,
build_graph_from_dependencies,
parse_agents,
validate_agent_names,
)
WORKFLOW_NAME = "MixtureOfAgents"
def build_graph(
*,
task: str,
agents: Iterable[WorkflowAgentSpec | dict[str, Any]],
aggregator: WorkflowAgentSpec | dict[str, Any],
) -> ExecutionGraph:
del task
experts = parse_agents(agents)
parsed_aggregator = parse_agents([aggregator])[0]
all_agents = [*experts, parsed_aggregator]
validate_agent_names(all_agents)
dependencies = {agent.name: [] for agent in all_agents}
dependencies[parsed_aggregator.name] = [agent.name for agent in experts]
return build_graph_from_dependencies(
workflow_name=WORKFLOW_NAME,
strategy="dag",
agents=all_agents,
dependencies=dependencies,
)

View File

@ -0,0 +1,29 @@
"""SequentialWorkflow graph builder."""
from __future__ import annotations
from typing import Any, Iterable
from beaver.coordinator.models import ExecutionGraph
from .base import WorkflowAgentSpec, build_graph_from_dependencies, parse_agents
WORKFLOW_NAME = "SequentialWorkflow"
def build_graph(
*,
task: str,
agents: Iterable[WorkflowAgentSpec | dict[str, Any]],
) -> ExecutionGraph:
del task
parsed = parse_agents(agents)
dependencies = {agent.name: [] for agent in parsed}
for previous, current in zip(parsed, parsed[1:], strict=False):
dependencies[current.name].append(previous.name)
return build_graph_from_dependencies(
workflow_name=WORKFLOW_NAME,
strategy="sequence",
agents=parsed,
dependencies=dependencies,
)

View File

@ -68,6 +68,15 @@ class MCPToolWrapper(BaseTool):
) )
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
if self.category == "team_workflow":
from beaver.team_workflows.executor import TeamWorkflowExecutor
return await TeamWorkflowExecutor().execute(
self.original_name,
dict(arguments or {}),
context,
tool_name=self.spec.name,
)
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
self.call_tool(self.original_name, dict(arguments or {})), self.call_tool(self.original_name, dict(arguments or {})),

View File

@ -1,10 +1,13 @@
import asyncio import asyncio
import json import json
from contextlib import suppress from contextlib import suppress
from types import SimpleNamespace
from typing import Any from typing import Any
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
from beaver.engine import loop as loop_module from beaver.engine import loop as loop_module
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle
def _run_result(run_id: str, output_text: str) -> AgentRunResult: def _run_result(run_id: str, output_text: str) -> AgentRunResult:
@ -49,7 +52,7 @@ def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
asyncio.run(run_case()) asyncio.run(run_case())
def test_web_search_loop_guard_stops_after_repeated_low_quality_results() -> None: def test_web_search_loop_guard_keeps_successful_low_quality_results_available() -> None:
guard = loop_module._WebSearchLoopGuard() guard = loop_module._WebSearchLoopGuard()
low_quality = json.dumps( low_quality = json.dumps(
{ {
@ -63,21 +66,106 @@ def test_web_search_loop_guard_stops_after_repeated_low_quality_results() -> Non
assert guard.observe_result("web_search", low_quality) is None assert guard.observe_result("web_search", low_quality) is None
assert guard.observe_result("web_search", low_quality) is None assert guard.observe_result("web_search", low_quality) is None
guidance = guard.observe_result("web_search", low_quality) assert guard.observe_result("web_search", low_quality) is None
assert guidance is not None
assert guidance["finish_reason"] == "web_search_low_quality_budget"
assert "weather beijing" in guidance["message"]
def test_web_search_loop_guard_resets_after_useful_result() -> None: def test_web_search_loop_guard_resets_after_useful_result() -> None:
guard = loop_module._WebSearchLoopGuard() guard = loop_module._WebSearchLoopGuard()
low_quality = json.dumps({"success": True, "query": "weather", "quality": "low", "results": []}) failed_search = json.dumps({"success": False, "query": "weather", "error": "timeout"})
useful = json.dumps({"success": True, "query": "weather", "quality": "high", "results": []}) useful = json.dumps({"success": True, "query": "weather", "quality": "high", "results": []})
assert guard.observe_result("web_search", low_quality) is None assert guard.observe_result("web_search", failed_search) is None
assert guard.observe_result("web_search", useful) is None assert guard.observe_result("web_search", useful) is None
assert guard.observe_result("web_search", low_quality) is None assert guard.observe_result("web_search", failed_search) is None
assert guard.observe_result("web_search", low_quality) is None assert guard.observe_result("web_search", failed_search) is None
assert guard.observe_result("web_search", low_quality) is not None assert guard.observe_result("web_search", failed_search) is not None
class RecordingProvider(LLMProvider):
def __init__(self) -> None:
super().__init__()
self.tool_names_by_call: list[list[str]] = []
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self.tool_names_by_call.append(
[
str(tool.get("function", {}).get("name") or tool.get("name"))
for tool in tools or []
]
)
return LLMResponse("done", provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
def _bundle(provider: RecordingProvider) -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider,
)
def test_task_mode_root_does_not_expose_agent_team_tool(tmp_path) -> None:
provider = RecordingProvider()
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
asyncio.run(
loop.process_direct(
"compare financial reports",
session_id="session",
task_id="task-1",
task_mode=True,
include_skill_assembly=False,
provider_bundle=_bundle(provider),
)
)
assert "run_agent_team" not in provider.tool_names_by_call[0]
def test_default_engine_registry_does_not_register_agent_team_tool(tmp_path) -> None:
loaded = AgentLoop(loader=EngineLoader(workspace=tmp_path)).boot()
assert loaded.tool_registry is not None
assert loaded.tool_registry.get("run_agent_team") is None
assert "run_agent_team" not in loaded.tools
def test_non_task_and_team_node_do_not_expose_agent_team_tool(tmp_path) -> None:
non_task_provider = RecordingProvider()
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
asyncio.run(
loop.process_direct(
"ordinary chat",
session_id="session",
include_skill_assembly=False,
provider_bundle=_bundle(non_task_provider),
)
)
team_node_provider = RecordingProvider()
asyncio.run(
loop.process_direct(
"team child",
session_id="session:team:child",
parent_session_id="session",
source="team:child",
task_id="task-1",
task_mode=True,
include_skill_assembly=False,
provider_bundle=_bundle(team_node_provider),
)
)
assert "run_agent_team" not in non_task_provider.tool_names_by_call[0]
assert "run_agent_team" not in team_node_provider.tool_names_by_call[0]

View File

@ -15,6 +15,7 @@ from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.context import SkillContext from beaver.engine.context import SkillContext
from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle from beaver.engine.providers.factory import ProviderBundle
from beaver.engine.session.manager import SessionManager
from beaver.services.team_service import TeamService from beaver.services.team_service import TeamService
from beaver.skills.assembler import SkillAssemblyResult from beaver.skills.assembler import SkillAssemblyResult
from beaver.skills.drafts import DraftService from beaver.skills.drafts import DraftService
@ -232,9 +233,9 @@ def test_unknown_evidence_requirement_makes_node_partial(tmp_path: Path) -> None
result = asyncio.run(LocalAgentRunner(loop).run(envelope, provider_bundle=_bundle(provider))) result = asyncio.run(LocalAgentRunner(loop).run(envelope, provider_bundle=_bundle(provider)))
assert result.success is False assert result.success is True
assert result.completion_status == "partial" assert result.completion_status == "succeeded"
assert result.evidence_gaps == ["unsupported evidence requirement: unknown_type"] assert result.evidence_gaps == []
def test_team_node_preserves_evidence_when_finish_reason_is_not_stop(tmp_path: Path) -> None: def test_team_node_preserves_evidence_when_finish_reason_is_not_stop(tmp_path: Path) -> None:
@ -257,6 +258,90 @@ def test_team_node_preserves_evidence_when_finish_reason_is_not_stop(tmp_path: P
assert result.evidence.finish_reason == "max_tool_iterations" assert result.evidence.finish_reason == "max_tool_iterations"
def test_team_node_accepts_finalized_tool_budget_output(tmp_path: Path) -> None:
loop = _loop(tmp_path)
provider = RecordingProvider([_response("usable finalized output", finish_reason="max_tool_iterations_finalized")])
envelope = DelegationEnvelope(
parent_task_id="task-parent",
parent_session_id="session-root",
parent_run_id="run-root",
agent=AgentDescriptor(name="researcher", role="research"),
task="research the requested topic",
node_id="research",
)
result = asyncio.run(LocalAgentRunner(loop).run(envelope, provider_bundle=_bundle(provider)))
assert result.success is True
assert result.completion_status == "succeeded"
assert result.finish_reason == "max_tool_iterations_finalized"
def test_team_node_rejects_finalized_raw_tool_call_output(tmp_path: Path) -> None:
loop = _loop(tmp_path)
provider = RecordingProvider(
[
_response(
'<DSMLtool_calls><DSMLinvoke name="web_fetch"></DSMLinvoke></DSMLtool_calls>',
finish_reason="max_tool_iterations_finalized",
)
]
)
envelope = DelegationEnvelope(
parent_task_id="task-parent",
parent_session_id="session-root",
parent_run_id="run-root",
agent=AgentDescriptor(name="researcher", role="research"),
task="research the requested topic",
node_id="research",
)
result = asyncio.run(LocalAgentRunner(loop).run(envelope, provider_bundle=_bundle(provider)))
assert result.success is False
assert result.completion_status == "failed"
assert result.error == "finalized output is a raw tool call"
def test_team_node_defaults_to_larger_tool_iteration_budget(tmp_path: Path) -> None:
session_manager = SessionManager(tmp_path)
captured_kwargs: dict[str, object] = {}
class CapturingLoop:
profile = SimpleNamespace()
loader = None
is_running = False
async def process_direct(self, task: str, **kwargs: object) -> SimpleNamespace:
captured_kwargs.update(kwargs)
session_id = str(kwargs["session_id"])
run_id = "run-captured"
session_manager.ensure_session(session_id, source="test")
return SimpleNamespace(
session_id=session_id,
run_id=run_id,
output_text="done",
finish_reason="stop",
)
def boot(self) -> SimpleNamespace:
return SimpleNamespace(session_manager=session_manager)
envelope = DelegationEnvelope(
parent_task_id="task-parent",
parent_session_id="session-root",
parent_run_id="run-root",
agent=AgentDescriptor(name="researcher", role="research"),
task="research the requested topic",
node_id="research",
)
result = asyncio.run(LocalAgentRunner(CapturingLoop()).run(envelope))
assert result.success is True
assert captured_kwargs["max_tool_iterations"] == 100
def test_pinned_skill_is_injected_into_delegated_run(tmp_path: Path) -> None: def test_pinned_skill_is_injected_into_delegated_run(tmp_path: Path) -> None:
_publish_skill( _publish_skill(
tmp_path, tmp_path,

View File

@ -323,6 +323,14 @@ def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
service.close() service.close()
def test_agent_default_tool_iteration_budget_is_100(tmp_path) -> None:
service = AgentService(config_path=tmp_path / "config.json")
assert service.profile.max_tool_iterations == 100
service.close()
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None: def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8") config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
@ -514,3 +522,16 @@ def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
assert local.managed is True assert local.managed is True
assert local.display_name == "个人智能体文件系统工具" assert local.display_name == "个人智能体文件系统工具"
assert "beaver.interfaces.mcp.tools_server" in local.args assert "beaver.interfaces.mcp.tools_server" in local.args
team_workflow = config.tools.mcp_servers["local_team_workflow_mcp"]
assert team_workflow.transport == "stdio"
assert team_workflow.kind == "local"
assert team_workflow.category == "team_workflow"
assert team_workflow.managed is True
assert team_workflow.display_name == "本地 Agent Team Workflow 工具"
assert team_workflow.args == [
"-m",
"beaver.interfaces.mcp.tools_server",
"--category",
"team_workflow",
]

View File

@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.engine import AgentRunResult
from beaver.tasks import TaskExecutionPlan, TaskRecord
from beaver.tasks.attempt_orchestrator import TaskAttemptOrchestrator
class FakeTaskService:
def start_run(self, task_id: str, **_: Any) -> None:
return None
def append_run(self, task_id: str, run_id: str, **_: Any) -> TaskRecord:
return self.task
class FakeSessionManager:
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def append_message(self, session_id: str, **kwargs: Any) -> None:
self.events.append({"session_id": session_id, **kwargs})
def update_latest_assistant_event_payload(self, *args: Any, **kwargs: Any) -> None:
return None
def get_run_event_records(self, session_id: str, run_id: str) -> list[Any]:
return []
class LegacyTeamPlanner:
async def plan(self, **_: Any) -> TaskExecutionPlan:
return TaskExecutionPlan(
mode="team",
reason="legacy plan should be ignored by orchestrator",
graph=ExecutionGraph(
strategy="sequence",
nodes=[
ExecutionNode("collect", "Collect", AgentDescriptor(name="collect")),
],
),
)
def _task() -> TaskRecord:
return TaskRecord(
task_id="task-1",
session_id="session-1",
description="finance comparison",
goal="finance comparison",
constraints=[],
priority=0,
status="open",
creator="test",
created_at="now",
updated_at="now",
)
def test_builtin_tools_do_not_export_legacy_agent_team_tool() -> None:
import beaver.tools.builtins as builtins
assert "AgentTeamTool" not in builtins.__all__
assert not hasattr(builtins, "AgentTeamTool")
def test_task_orchestrator_does_not_execute_legacy_planner_team_graph() -> None:
task = _task()
task_service = FakeTaskService()
task_service.task = task
session_manager = FakeSessionManager()
loaded = SimpleNamespace(
task_service=task_service,
task_execution_planner=LegacyTeamPlanner(),
session_manager=session_manager,
run_memory_store=None,
)
orchestrator = TaskAttemptOrchestrator(
loaded=loaded,
create_loop=lambda: None,
make_provider_bundle_for_task=lambda *_: None,
)
async def fail_if_called(*args: Any, **kwargs: Any) -> Any:
raise AssertionError("legacy planner team graph must not start TeamService")
async def runner(message: str, **kwargs: Any) -> AgentRunResult:
return AgentRunResult(
session_id="session-1",
run_id="main-run",
output_text="single path",
finish_reason="stop",
tool_iterations=0,
)
orchestrator._run_team_for_task = fail_if_called # type: ignore[method-assign]
result = asyncio.run(
orchestrator.run(
message="compare finance",
runner=runner,
kwargs={
"session_id": "session-1",
"provider_bundle": SimpleNamespace(),
"include_skill_assembly": False,
},
task=task,
)
)
assert result.output_text == "single path"
synthesis_events = [
event
for event in session_manager.events
if event.get("event_type") == "task_synthesis_completed"
]
assert synthesis_events[0]["event_payload"]["task_outcome"] == "single"

View File

@ -20,3 +20,30 @@ def test_local_filesystem_mcp_exposes_personal_user_file_tools_only(tmp_path) ->
assert "search_files" not in names assert "search_files" not in names
assert "list_directory" not in names assert "list_directory" not in names
assert all("personal agent file system" in tool.spec.description for tool in tools) assert all("personal agent file system" in tool.spec.description for tool in tools)
def test_team_workflow_mcp_exposes_workflow_tool_schemas(tmp_path) -> None:
tools, _context = _category_tools("team_workflow", tmp_path)
specs = {tool.spec.name: tool.spec for tool in tools}
assert list(specs) == [
"SequentialWorkflow",
"ConcurrentWorkflow",
"MixtureOfAgents",
"AgentRearrange",
"GraphWorkflow",
]
assert specs["SequentialWorkflow"].input_schema["required"] == ["task", "agents"]
assert specs["SequentialWorkflow"].input_schema["properties"]["agents"]["items"]["required"] == [
"name",
"instruction",
]
assert specs["GraphWorkflow"].input_schema["required"] == [
"task",
"agents",
"edges",
"output_agent",
]
assert specs["GraphWorkflow"].input_schema["properties"]["edges"]["items"]["minItems"] == 2
assert specs["AgentRearrange"].input_schema["required"] == ["task", "agents", "flow"]

View File

@ -205,6 +205,7 @@ def test_process_projection_maps_failed_task_team_events(tmp_path: Path) -> None
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created") team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
assert team_event["status"] == "error" assert team_event["status"] == "error"
assert team_event["text"] == "Team 执行未完成 / 子节点失败"
assert team_event["metadata"]["timeline_type"] == "agent_team" assert team_event["metadata"]["timeline_type"] == "agent_team"
assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"] assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"]
@ -297,6 +298,101 @@ def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Pa
assert skill_events assert skill_events
assert skill_events[0]["metadata"]["timeline_type"] == "skill" assert skill_events[0]["metadata"]["timeline_type"] == "skill"
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"] assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
assert skill_events[0]["created_at"] == "2026-01-01T00:00:03+00:00"
def test_process_projection_prefers_skill_activation_snapshot_over_synthesis_fallback(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",
run_id="main-run",
role="system",
event_type="skill_activation_snapshotted",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"receipts": [
{
"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.",
}
],
},
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", "attempt_index": 1},
content="Searching",
tool_calls=[{"id": "call-1", "name": "web_fetch", "arguments": {"url": "https://example.com"}}],
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 len(skill_events) == 1
assert skill_events[0]["event_id"].endswith(":skill-activation")
assert skill_events[0]["created_at"] == "2026-01-01T00:00:03+00:00"
tool_event = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
assert projection["events"].index(skill_events[0]) < projection["events"].index(tool_event)
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None: def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:

View File

@ -2,38 +2,9 @@ from __future__ import annotations
from beaver.skills.assembler.task_assembler import SkillAssembler from beaver.skills.assembler.task_assembler import SkillAssembler
from beaver.skills.catalog.loader import SkillsLoader from beaver.skills.catalog.loader import SkillsLoader
from beaver.skills.catalog.utils import extract_skill_team_template
def test_extract_team_template_returns_none_when_block_is_absent() -> None: def test_beaver_team_template_block_is_not_runtime_metadata(tmp_path) -> None:
result = extract_skill_team_template("# Ordinary Skill")
assert result.template is None
assert result.warnings == []
def test_extract_team_template_parses_valid_json_block() -> None:
result = extract_skill_team_template(
"```beaver-team-template\n"
'{"version": 1, "nodes": [{"node_id": "collect", "task": "Collect"}]}\n'
"```"
)
assert result.template == {
"version": 1,
"nodes": [{"node_id": "collect", "task": "Collect"}],
}
assert result.warnings == []
def test_invalid_template_is_warning_not_skill_load_failure() -> None:
result = extract_skill_team_template("```beaver-team-template\nnot-json\n```")
assert result.template is None
assert result.warnings == ["team template JSON is invalid"]
def test_loader_and_assembler_propagate_team_template_to_skill_context(tmp_path) -> None:
skill_dir = tmp_path / "plugin-skills" / "financial-comparison" skill_dir = tmp_path / "plugin-skills" / "financial-comparison"
skill_dir.mkdir(parents=True) skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text( (skill_dir / "SKILL.md").write_text(
@ -56,10 +27,7 @@ def test_loader_and_assembler_propagate_team_template_to_skill_context(tmp_path)
context = SkillAssembler(loader)._activate_skill_contexts(["financial-comparison"])[0] context = SkillAssembler(loader)._activate_skill_contexts(["financial-comparison"])[0]
assert record is not None assert record is not None
assert record.team_template == { assert not hasattr(record, "team_template")
"version": 1, assert not hasattr(record, "team_template_warnings")
"nodes": [{"node_id": "collect", "task": "Collect official sources"}], assert not hasattr(context, "team_template")
} assert not hasattr(context, "team_template_warnings")
assert record.team_template_warnings == []
assert context.team_template == record.team_template
assert context.team_template_warnings == []

View File

@ -55,12 +55,11 @@ def test_evaluate_node_evidence_accepts_url_in_successful_tool_content() -> None
assert evaluate_node_evidence(evidence, ["tool_result", "url"], "done") == [] assert evaluate_node_evidence(evidence, ["tool_result", "url"], "done") == []
def test_evaluate_node_evidence_checks_output_and_unknown_requirements() -> None: def test_evaluate_node_evidence_checks_output_and_ignores_natural_language_requirements() -> None:
evidence = _run_evidence() evidence = _run_evidence()
assert evaluate_node_evidence(evidence, ["output", "unknown_type"], " ") == [ assert evaluate_node_evidence(evidence, ["output", "至少3个价格信息来源"], " ") == [
"missing required evidence: output", "missing required evidence: output",
"unsupported evidence requirement: unknown_type",
] ]

View File

@ -3,19 +3,15 @@ from __future__ import annotations
import asyncio import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from beaver.engine.context import SkillContext
from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle from beaver.engine.providers.factory import ProviderBundle
from beaver.tasks import SkillResolutionReport, TaskExecutionPlanner, TaskRecord from beaver.tasks import TaskExecutionPlanner, TaskRecord
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
from beaver.tools.registry import ToolRegistry
class PlannerProvider(LLMProvider): class PlannerProvider(LLMProvider):
def __init__(self, response: str) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.response = response self.calls = 0
self.calls: list[dict] = []
async def chat( async def chat(
self, self,
@ -25,59 +21,18 @@ class PlannerProvider(LLMProvider):
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
) -> LLMResponse: ) -> LLMResponse:
self.calls.append( self.calls += 1
{ return LLMResponse(
"messages": messages, content='{"mode":"team"}',
"max_tokens": max_tokens, finish_reason="stop",
"temperature": temperature, provider_name="stub",
"model": model, model="stub-model",
"tools": tools,
}
) )
return LLMResponse(content=self.response, finish_reason="stop", provider_name="stub", model="stub-model")
def get_default_model(self) -> str: def get_default_model(self) -> str:
return "stub-model" return "stub-model"
class HangingPlannerProvider(LLMProvider):
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
await asyncio.sleep(10)
return LLMResponse(content='{"mode":"team"}', finish_reason="stop", provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
class SequencedPlannerProvider(PlannerProvider):
def __init__(self, responses: list[str]) -> None:
super().__init__(responses[0])
self.responses = list(responses)
async def chat(self, *args, **kwargs) -> LLMResponse:
self.response = self.responses.pop(0)
return await super().chat(*args, **kwargs)
class StubTool(BaseTool):
def __init__(self, name: str) -> None:
self._spec = ToolSpec(name=name, description=name, input_schema={"type": "object"})
@property
def spec(self) -> ToolSpec:
return self._spec
async def invoke(self, arguments: dict, context: ToolContext) -> ToolResult:
raise AssertionError("Planner tests do not execute tools")
def _task() -> TaskRecord: def _task() -> TaskRecord:
return TaskRecord( return TaskRecord(
task_id="task-1", task_id="task-1",
@ -93,55 +48,15 @@ def _task() -> TaskRecord:
) )
def _bundle(response: str) -> ProviderBundle: def _bundle(provider: PlannerProvider) -> ProviderBundle:
provider = PlannerProvider(response)
return ProviderBundle( return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider, main_provider=provider,
) )
def _bundle_with_provider(provider: LLMProvider) -> ProviderBundle: def test_planner_skips_provider_for_simple_task() -> None:
return ProviderBundle( provider = PlannerProvider()
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider,
)
def _registry() -> ToolRegistry:
registry = ToolRegistry()
registry.register_many([StubTool("web_search"), StubTool("web_fetch"), StubTool("terminal")])
return registry
def _hanging_bundle() -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=HangingPlannerProvider(),
)
def test_planner_selects_single_mode() -> None:
plan = asyncio.run(
TaskExecutionPlanner().plan(
task=_task(),
user_message="implement workflow",
attempt_index=1,
provider_bundle=_bundle('{"mode":"single","reason":"main agent is enough"}'),
)
)
assert plan.mode == "single"
assert plan.graph is None
assert plan.reason == "main agent is enough"
def test_planner_skips_llm_for_simple_task() -> None:
provider = PlannerProvider('{"mode":"team","reason":"should not be used"}')
bundle = ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider,
)
task = _task() task = _task()
task.description = "查询深圳天气" task.description = "查询深圳天气"
task.goal = "查询深圳天气" task.goal = "查询深圳天气"
@ -151,409 +66,55 @@ def test_planner_skips_llm_for_simple_task() -> None:
task=task, task=task,
user_message="帮我查一下今天深圳天气", user_message="帮我查一下今天深圳天气",
attempt_index=1, attempt_index=1,
provider_bundle=bundle, provider_bundle=_bundle(provider),
) )
) )
assert plan.mode == "single" assert plan.mode == "single"
assert plan.graph is None assert plan.graph is None
assert plan.reason == "planner_skipped_simple_task" assert plan.reason == "planner_skipped_simple_task"
assert provider.calls == [] assert provider.calls == 0
def test_planner_builds_team_graph() -> None: def test_planner_replaces_team_planning_with_workflow_tools_without_provider_call() -> None:
bundle = _bundle( provider = PlannerProvider()
"""
{
"mode": "team",
"reason": "needs parallel review",
"strategy": "dag",
"nodes": [
{"node_id": "research", "task": "research options"},
{"node_id": "review", "task": "review result", "depends_on": ["research"]}
],
"final_synthesis_instruction": "merge the findings"
}
"""
)
provider = bundle.main_provider
plan = asyncio.run( plan = asyncio.run(
TaskExecutionPlanner().plan( TaskExecutionPlanner().plan(
task=_task(), task=_task(),
user_message="implement workflow", user_message="research and compare workflow options",
attempt_index=1, attempt_index=1,
provider_bundle=bundle, provider_bundle=_bundle(provider),
skill_summaries=["docker-debug: Use docker logs before editing config."], skill_summaries=["docker-debug: Use docker logs before editing config."],
tool_hints=["terminal", "search_files"], tool_hints=["terminal", "search_files"],
) )
) )
assert plan.is_team assert not plan.is_team
assert plan.graph is not None assert plan.mode == "single"
assert plan.graph.strategy == "dag" assert plan.graph is None
assert [node.node_id for node in plan.graph.nodes] == ["research", "review"] assert plan.reason == "planner_team_replaced_by_workflow_tools"
assert plan.graph.nodes[1].depends_on == ["research"] assert plan.final_synthesis_instruction == ""
assert plan.final_synthesis_instruction == "merge the findings" assert provider.calls == 0
assert isinstance(provider, PlannerProvider)
prompt = provider.calls[0]["messages"][1]["content"]
assert "Activated skill summaries" in prompt
assert "docker-debug: Use docker logs before editing config." in prompt
assert "terminal" in prompt
assert "search_files" in prompt
def test_planner_timeout_falls_back_to_single() -> None: def test_planner_can_be_disabled_by_environment(monkeypatch) -> None:
monkeypatch.setenv("BEAVER_AGENT_TEAM_ENABLED", "0")
provider = PlannerProvider()
plan = asyncio.run( plan = asyncio.run(
TaskExecutionPlanner().plan( TaskExecutionPlanner().plan(
task=_task(), task=_task(),
user_message="implement workflow", user_message="research and compare workflow options",
attempt_index=1, attempt_index=1,
provider_bundle=_hanging_bundle(), provider_bundle=_bundle(provider),
timeout_seconds=0.01,
) )
) )
assert plan.mode == "single" assert plan.mode == "single"
assert plan.reason == "planner_failed" assert plan.reason == "planner_disabled_by_environment"
assert "TimeoutError" in (plan.fallback_error or "") assert provider.calls == 0
def test_planner_team_nodes_use_task_as_internal_skill_query() -> None: def test_planner_no_longer_exposes_json_to_team_graph_parser() -> None:
plan = TaskExecutionPlanner().from_json( assert not hasattr(TaskExecutionPlanner(), "from_json")
"""
{
"mode": "team",
"reason": "needs skill-guided review",
"strategy": "sequence",
"nodes": [
{
"node_id": "api_review",
"task": "review API compatibility"
}
]
}
"""
)
assert plan.is_team
assert plan.graph is not None
node = plan.graph.nodes[0]
assert node.agent.name == "api_review"
assert node.agent.role == ""
assert node.agent.metadata["skill_query"] == "review API compatibility"
assert node.agent.metadata["required_capabilities"] == []
def test_planner_accepts_use_skill_and_skill_query() -> None:
plan = TaskExecutionPlanner().from_json(
"""
{
"mode": "team",
"strategy": "sequence",
"nodes": [
{
"node_id": "collect",
"task": "Collect official sources",
"use_skill": "official-source-research",
"skill_query": "official source verification"
}
]
}
"""
)
assert plan.is_team
assert plan.graph is not None
node = plan.graph.nodes[0]
assert node.agent.metadata["use_skill"] == "official-source-research"
assert node.agent.metadata["skill_query"] == "official source verification"
assert node.inherited_pinned_skills == []
assert node.allowed_tool_names is None
assert plan.planner_adaptation["node_skill_bindings"] == [
{
"node_id": "collect",
"use_skill": "official-source-research",
"skill_query": "official source verification",
}
]
def test_planner_defaults_skill_query_to_node_task_when_absent() -> None:
plan = TaskExecutionPlanner().from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"extract","task":"Extract financial metrics","use_skill":"financial-extraction"}]}'
)
assert plan.is_team
assert plan.graph is not None
assert plan.graph.nodes[0].agent.metadata["skill_query"] == "Extract financial metrics"
def test_planner_adaptation_records_unresolved_use_skill_fallback() -> None:
planner = TaskExecutionPlanner()
plan = planner.from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"extract","task":"Extract metrics","use_skill":"missing-skill",'
'"skill_query":"financial extraction"}]}'
)
report = SkillResolutionReport(
node_id="extract",
skill_query="financial extraction",
requested_skill_name="missing-skill",
exact_binding_used=False,
warnings=["use_skill unresolved: missing-skill"],
reason="matched published skill",
)
planner._merge_skill_resolution_adaptation(plan, [report])
assert plan.planner_adaptation["warnings"] == ["use_skill unresolved: missing-skill"]
assert plan.planner_adaptation["node_skill_bindings"][0]["fallback_reason"] == (
"use_skill unresolved; matched published skill"
)
def test_planner_invalid_outputs_fallback_to_single() -> None:
planner = TaskExecutionPlanner()
invalid_json = planner.from_json("not json")
unknown_strategy = planner.from_json(
'{"mode":"team","strategy":"moa","nodes":[{"node_id":"a","task":"a","agent":{"name":"a"}}]}'
)
too_many_nodes = planner.from_json(
'{"mode":"team","strategy":"parallel","nodes":['
+ ",".join(
'{"node_id":"n%s","task":"work","agent":{"name":"n%s"}}' % (index, index)
for index in range(7)
)
+ "]}"
)
cyclic = planner.from_json(
"""
{
"mode": "team",
"strategy": "dag",
"nodes": [
{"node_id": "a", "task": "a", "agent": {"name": "a"}, "depends_on": ["b"]},
{"node_id": "b", "task": "b", "agent": {"name": "b"}, "depends_on": ["a"]}
]
}
"""
)
assert invalid_json.mode == "single"
assert unknown_strategy.mode == "single"
assert too_many_nodes.mode == "single"
assert cyclic.mode == "single"
def test_template_plan_builds_generic_worker_and_preserves_v1_contract_fields() -> None:
plan = TaskExecutionPlanner(tool_registry=_registry()).from_json(
"""
{
"mode": "team",
"strategy": "dag",
"nodes": [
{
"node_id": "collect",
"task": "Collect official sources",
"requested_tools": ["web_search"],
"evidence_contract": {"entities": ["MGM", "Galaxy"]},
"block_downstream_on_partial": true
}
],
"adaptation": {"template_used": true}
}
"""
)
assert plan.is_team
assert plan.graph is not None
node = plan.graph.nodes[0]
assert node.agent.name == "collect"
assert node.agent.role == ""
assert node.agent.metadata["sub_agent_kind"] == "generic_skill_worker"
assert node.allowed_tool_names == ["web_search"]
assert node.evidence_contract == {"entities": ["MGM", "Galaxy"]}
assert node.block_downstream_on_partial is True
assert plan.planner_adaptation["template_used"] is True
def test_unknown_tool_is_removed_and_warned() -> None:
plan = TaskExecutionPlanner(tool_registry=_registry()).from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"collect","task":"Collect","requested_tools":["web_search","not_real"]}]}'
)
assert plan.is_team
assert plan.graph is not None
assert plan.graph.nodes[0].allowed_tool_names == ["web_search"]
assert "unknown tool removed: not_real" in plan.planner_adaptation["warnings"]
def test_high_risk_tool_is_removed_without_failing_low_risk_plan() -> None:
plan = TaskExecutionPlanner(tool_registry=_registry()).from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"collect","task":"Collect","requested_tools":["web_search","terminal"]}]}'
)
assert plan.is_team
assert plan.graph is not None
assert plan.graph.nodes[0].allowed_tool_names == ["web_search"]
assert "requires_high_risk_review: terminal" in plan.planner_adaptation["warnings"]
def test_planner_rejects_agent_and_role_node_fields() -> None:
planner = TaskExecutionPlanner(tool_registry=_registry())
agent_plan = planner.from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"collect","task":"Collect","agent":{"name":"researcher"}}]}'
)
role_plan = planner.from_json(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"collect","task":"Collect","role":"researcher"}]}'
)
assert agent_plan.mode == "single"
assert "agent" in (agent_plan.fallback_error or "")
assert role_plan.mode == "single"
assert "role" in (role_plan.fallback_error or "")
def test_planner_records_primary_template_selection_and_ignored_templates() -> None:
primary = SkillContext(
name="financial-comparison",
version="v1",
content="Compare official financial disclosures.",
team_template={"version": 1, "nodes": [{"node_id": "collect", "task": "Collect"}]},
)
secondary = SkillContext(
name="chart-reporting",
version="v2",
content="Render chart-ready Markdown.",
team_template={"version": 1, "nodes": [{"node_id": "report", "task": "Report"}]},
)
provider = PlannerProvider(
'{"mode":"team","strategy":"sequence","nodes":['
'{"node_id":"collect","task":"Collect official sources"}],'
'"adaptation":{"template_used":true}}'
)
plan = asyncio.run(
TaskExecutionPlanner(tool_registry=_registry()).plan(
task=_task(),
user_message="compare financial workflow",
attempt_index=1,
provider_bundle=_bundle_with_provider(provider),
activated_skills=[primary, secondary],
)
)
assert plan.planner_adaptation == {
"template_used": True,
"selected_template": "financial-comparison",
"selection_reason": "first activated skill with a valid team template",
"ignored_templates": ["chart-reporting"],
"warnings": [],
}
prompt = provider.calls[0]["messages"][1]["content"]
assert '"skill_name": "financial-comparison"' in prompt
assert "Compare official financial disclosures." in prompt
assert "Render chart-ready Markdown." in prompt
def test_malformed_planner_output_repairs_once_without_tools() -> None:
provider = SequencedPlannerProvider(
[
"not json",
'{"mode":"team","strategy":"sequence","nodes":[{"node_id":"collect","task":"Collect"}]}',
]
)
plan = asyncio.run(
TaskExecutionPlanner(tool_registry=_registry()).plan(
task=_task(),
user_message="implement workflow",
attempt_index=1,
provider_bundle=_bundle_with_provider(provider),
)
)
assert plan.is_team
assert len(provider.calls) == 2
assert provider.calls[1]["tools"] is None
assert "Repair the invalid planner JSON" in provider.calls[1]["messages"][1]["content"]
def test_failed_planner_repair_falls_back_to_single() -> None:
provider = SequencedPlannerProvider(["not json", "still not json"])
plan = asyncio.run(
TaskExecutionPlanner(tool_registry=_registry()).plan(
task=_task(),
user_message="implement workflow",
attempt_index=1,
provider_bundle=_bundle_with_provider(provider),
)
)
assert plan.mode == "single"
assert plan.reason == "planner_fallback_single"
assert len(provider.calls) == 2
def test_finance_template_adapts_to_task_oriented_read_only_graph() -> None:
plan = TaskExecutionPlanner(tool_registry=_registry()).from_json(
"""
{
"mode": "team",
"strategy": "dag",
"nodes": [
{
"node_id": "collect_official_sources",
"task": "Collect MGM and Galaxy official financial disclosures",
"requested_tools": ["web_search", "web_fetch"],
"required_evidence": ["tool_result", "url"]
},
{
"node_id": "extract_financial_metrics",
"task": "Extract comparable financial metrics from collected sources",
"depends_on": ["collect_official_sources"],
"requested_tools": ["web_fetch"],
"required_evidence": ["output"]
},
{
"node_id": "validate_metrics",
"task": "Validate metric units, periods, and source consistency",
"depends_on": ["extract_financial_metrics"],
"required_evidence": ["output"]
},
{
"node_id": "generate_chart_report",
"task": "Generate a Markdown comparison table and chart-ready data without claiming an image or file artifact",
"depends_on": ["validate_metrics"],
"requested_tools": [],
"required_evidence": ["output"]
}
]
}
"""
)
assert plan.is_team
assert plan.graph is not None
assert [node.node_id for node in plan.graph.nodes] == [
"collect_official_sources",
"extract_financial_metrics",
"validate_metrics",
"generate_chart_report",
]
assert all(node.agent.role == "" for node in plan.graph.nodes)
assert not {"researcher", "writer", "reviewer", "analyst"}.intersection(
node.node_id for node in plan.graph.nodes
)
assert plan.graph.nodes[0].allowed_tool_names == ["web_search", "web_fetch"]
assert plan.graph.nodes[-1].allowed_tool_names == []
report_task = plan.graph.nodes[-1].task.lower()
assert "markdown" in report_task
assert "without claiming an image or file artifact" in report_task

View File

@ -1,233 +0,0 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode, NodeRunResult, TeamRunResult
from beaver.engine import AgentRunResult
from beaver.tasks import TaskExecutionPlan, TaskRecord
from beaver.tasks.attempt_orchestrator import TaskAttemptOrchestrator
def _plan(*, optional_second: bool = False) -> TaskExecutionPlan:
return TaskExecutionPlan(
mode="team",
reason="test team",
graph=ExecutionGraph(
strategy="sequence",
nodes=[
ExecutionNode("collect", "Collect", AgentDescriptor(name="collect")),
ExecutionNode(
"report",
"Report",
AgentDescriptor(name="report"),
required_for_completion=not optional_second,
),
],
),
)
def _team_result(*results: NodeRunResult) -> TeamRunResult:
return TeamRunResult(
success=all(result.success for result in results),
summary="team summary",
node_results=list(results),
)
def _result(node_id: str, status: str, *, gaps: list[str] | None = None) -> NodeRunResult:
return NodeRunResult(
node_id=node_id,
success=status == "succeeded",
output_text=f"{node_id} output",
finish_reason="blocked" if status == "blocked" else "stop",
error=None if status == "succeeded" else f"{status} node",
completion_status=status,
evidence_gaps=list(gaps or []),
)
def test_required_partial_node_marks_synthesis_incomplete() -> None:
context, prefix, metadata = TaskAttemptOrchestrator._team_synthesis_outcome(
_plan(),
_team_result(
_result("collect", "partial", gaps=["missing required evidence: url"]),
_result("report", "succeeded"),
),
)
assert metadata["task_outcome"] == "incomplete"
assert metadata["incomplete_node_ids"] == ["collect"]
assert metadata["evidence_gaps"] == {"collect": ["missing required evidence: url"]}
assert "Task outcome: incomplete" in context
assert "missing required evidence: url" in context
assert prefix.startswith("任务未完成:")
@pytest.mark.parametrize("status", ["failed", "blocked"])
def test_required_failed_or_blocked_node_marks_synthesis_incomplete(status: str) -> None:
_, prefix, metadata = TaskAttemptOrchestrator._team_synthesis_outcome(
_plan(),
_team_result(_result("collect", status), _result("report", "succeeded")),
)
assert metadata["task_outcome"] == "incomplete"
assert metadata["incomplete_node_ids"] == ["collect"]
assert metadata["node_statuses"]["collect"] == status
assert prefix
def test_optional_failed_node_does_not_force_incomplete() -> None:
context, prefix, metadata = TaskAttemptOrchestrator._team_synthesis_outcome(
_plan(optional_second=True),
_team_result(_result("collect", "succeeded"), _result("report", "failed")),
)
assert metadata["task_outcome"] == "complete"
assert metadata["incomplete_node_ids"] == []
assert "Task outcome: complete" in context
assert prefix == ""
def test_all_required_nodes_succeeded_is_complete() -> None:
_, prefix, metadata = TaskAttemptOrchestrator._team_synthesis_outcome(
_plan(),
_team_result(_result("collect", "succeeded"), _result("report", "succeeded")),
)
assert metadata["task_outcome"] == "complete"
assert prefix == ""
def test_single_plan_outcome_does_not_add_prefix() -> None:
context, prefix, metadata = TaskAttemptOrchestrator._team_synthesis_outcome(
TaskExecutionPlan.single("single"),
None,
)
assert metadata["task_outcome"] == "single"
assert "Task outcome: single" in context
assert prefix == ""
class FakeTaskService:
def start_run(self, task_id: str, **_: Any) -> None:
return None
def append_run(self, task_id: str, run_id: str, **_: Any) -> TaskRecord:
return self.task
class FakeSessionManager:
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def append_message(self, session_id: str, **kwargs: Any) -> None:
self.events.append({"session_id": session_id, **kwargs})
def update_latest_assistant_event_payload(self, *args: Any, **kwargs: Any) -> None:
return None
def get_run_event_records(self, session_id: str, run_id: str) -> list[Any]:
return []
class FixedPlanner:
def __init__(self, plan: TaskExecutionPlan) -> None:
self.fixed_plan = plan
async def plan(self, **_: Any) -> TaskExecutionPlan:
return self.fixed_plan
def _task() -> TaskRecord:
return TaskRecord(
task_id="task-1",
session_id="session-1",
description="finance comparison",
goal="finance comparison",
constraints=[],
priority=0,
status="open",
creator="test",
created_at="now",
updated_at="now",
)
def test_incomplete_team_still_runs_tool_free_synthesis_and_prefixes_output() -> None:
plan = _plan()
team_result = _team_result(
_result("collect", "partial", gaps=["missing required evidence: url"]),
_result("report", "succeeded"),
)
task = _task()
task_service = FakeTaskService()
task_service.task = task
session_manager = FakeSessionManager()
loaded = SimpleNamespace(
task_service=task_service,
task_execution_planner=FixedPlanner(plan),
session_manager=session_manager,
run_memory_store=None,
)
orchestrator = TaskAttemptOrchestrator(
loaded=loaded,
create_loop=lambda: None,
make_provider_bundle_for_task=lambda *_: None,
)
async def fake_run_team(*args: Any, **kwargs: Any) -> tuple[TeamRunResult, None]:
return team_result, None
runner_calls: list[dict[str, Any]] = []
async def runner(message: str, **kwargs: Any) -> AgentRunResult:
runner_calls.append(kwargs)
return AgentRunResult(
session_id="session-1",
run_id="main-run",
output_text="Available financial comparison.",
finish_reason="stop",
tool_iterations=0,
)
orchestrator._run_team_for_task = fake_run_team # type: ignore[method-assign]
result = asyncio.run(
orchestrator.run(
message="compare finance",
runner=runner,
kwargs={
"session_id": "session-1",
"provider_bundle": SimpleNamespace(),
"include_skill_assembly": False,
},
task=task,
)
)
assert len(runner_calls) == 1
assert runner_calls[0]["include_tools"] is False
assert runner_calls[0]["max_tool_iterations"] == 0
assert "Task outcome: incomplete" in runner_calls[0]["execution_context"]
assert result.output_text.startswith("任务未完成:")
synthesis_event = [event for event in session_manager.events if event.get("event_type") == "task_synthesis_completed"][0]
assert synthesis_event["event_payload"]["task_outcome"] == "incomplete"
assert synthesis_event["event_payload"]["incomplete_node_ids"] == ["collect"]
assert synthesis_event["event_payload"]["node_statuses"] == {
"collect": "partial",
"report": "succeeded",
}
assert synthesis_event["event_payload"]["evidence_gaps"] == {
"collect": ["missing required evidence: url"]
}
def test_incomplete_notice_is_not_prefixed_twice() -> None:
text = "任务未完成:缺少官方来源。"
assert TaskAttemptOrchestrator._apply_incomplete_prefix(text, "任务未完成:部分步骤缺少证据。\n\n") == text

View File

@ -0,0 +1,214 @@
from __future__ import annotations
import pytest
from beaver.team_workflows.agent_rearrange import build_graph as build_rearrange_graph
from beaver.team_workflows.concurrent import build_graph as build_concurrent_graph
from beaver.team_workflows.graph import build_graph as build_explicit_graph
from beaver.team_workflows.mixture_of_agents import build_graph as build_moa_graph
from beaver.team_workflows.sequential import build_graph as build_sequential_graph
def _deps(graph) -> dict[str, list[str]]:
return {node.node_id: list(node.depends_on) for node in graph.nodes}
def test_sequential_workflow_builds_chain_and_preserves_agent_fields() -> None:
graph = build_sequential_graph(
task="finance report",
agents=[
{
"name": "source_collector",
"instruction": "Collect official sources",
"skill_query": "official filings",
"allowed_tool_names": ["web_search", "web_fetch"],
"required_evidence": ["url"],
"validation_rules": ["Prefer official sources."],
"block_downstream_on_partial": True,
},
{"name": "metric_extractor", "instruction": "Extract metrics"},
{"name": "reporter", "instruction": "Write report"},
],
)
assert graph.strategy == "sequence"
assert [node.node_id for node in graph.nodes] == [
"source_collector",
"metric_extractor",
"reporter",
]
assert _deps(graph) == {
"source_collector": [],
"metric_extractor": ["source_collector"],
"reporter": ["metric_extractor"],
}
first = graph.nodes[0]
assert first.task == "Collect official sources"
assert first.agent.role == ""
assert first.agent.metadata["sub_agent_kind"] == "generic_skill_worker"
assert first.agent.metadata["workflow_tool"] == "SequentialWorkflow"
assert first.agent.metadata["workflow_agent_name"] == "source_collector"
assert first.agent.metadata["skill_query"] == "official filings"
assert first.allowed_tool_names == ["web_search", "web_fetch"]
assert first.required_evidence == ["url"]
assert first.validation_rules == ["Prefer official sources."]
assert first.block_downstream_on_partial is True
def test_concurrent_workflow_builds_independent_nodes() -> None:
graph = build_concurrent_graph(
task="research topic",
agents=[
{"name": "official_sources", "instruction": "Check official sources"},
{"name": "media_sources", "instruction": "Check media sources"},
{"name": "data_sources", "instruction": "Check data sources"},
],
)
assert graph.strategy == "parallel"
assert _deps(graph) == {
"official_sources": [],
"media_sources": [],
"data_sources": [],
}
def test_mixture_of_agents_builds_experts_to_aggregator() -> None:
graph = build_moa_graph(
task="analyze match",
agents=[
{"name": "tactics", "instruction": "Analyze tactics"},
{"name": "players", "instruction": "Analyze players"},
{"name": "media", "instruction": "Analyze media"},
],
aggregator={"name": "synthesizer", "instruction": "Synthesize report"},
)
assert graph.strategy == "dag"
assert _deps(graph) == {
"tactics": [],
"players": [],
"media": [],
"synthesizer": ["tactics", "players", "media"],
}
assert graph.nodes[-1].agent.metadata["workflow_tool"] == "MixtureOfAgents"
def test_agent_rearrange_parses_flow_into_edges() -> None:
graph = build_rearrange_graph(
task="collect then analyze then synthesize",
agents=[
{"name": "collector", "instruction": "Collect facts"},
{"name": "tactics", "instruction": "Analyze tactics"},
{"name": "players", "instruction": "Analyze players"},
{"name": "media", "instruction": "Analyze media"},
{"name": "synthesizer", "instruction": "Synthesize report"},
],
flow="collector -> tactics, players, media -> synthesizer",
)
assert graph.strategy == "dag"
assert _deps(graph) == {
"collector": [],
"tactics": ["collector"],
"players": ["collector"],
"media": ["collector"],
"synthesizer": ["tactics", "players", "media"],
}
def test_agent_rearrange_rejects_unknown_agent_in_flow() -> None:
with pytest.raises(ValueError, match="unknown agent"):
build_rearrange_graph(
task="bad flow",
agents=[{"name": "collector", "instruction": "Collect"}],
flow="collector -> missing",
)
def test_graph_workflow_requires_edges_and_output_agent() -> None:
with pytest.raises(ValueError, match="edges"):
build_explicit_graph(
task="bad graph",
agents=[{"name": "collector", "instruction": "Collect"}],
edges=[],
output_agent="collector",
)
with pytest.raises(ValueError, match="output_agent"):
build_explicit_graph(
task="bad graph",
agents=[
{"name": "collector", "instruction": "Collect"},
{"name": "reporter", "instruction": "Report"},
],
edges=[["collector", "reporter"]],
output_agent="missing",
)
def test_graph_workflow_builds_explicit_dag() -> None:
graph = build_explicit_graph(
task="match analysis",
agents=[
{"name": "collector", "instruction": "Collect facts"},
{"name": "tactics", "instruction": "Analyze tactics"},
{"name": "players", "instruction": "Analyze players"},
{"name": "media", "instruction": "Analyze media"},
{"name": "synthesizer", "instruction": "Synthesize report"},
],
edges=[
["collector", "tactics"],
["collector", "players"],
["collector", "media"],
["tactics", "synthesizer"],
["players", "synthesizer"],
["media", "synthesizer"],
],
output_agent="synthesizer",
)
assert graph.strategy == "dag"
assert _deps(graph) == {
"collector": [],
"tactics": ["collector"],
"players": ["collector"],
"media": ["collector"],
"synthesizer": ["tactics", "players", "media"],
}
def test_graph_workflow_rejects_unknown_cycle_and_disconnected_agents() -> None:
with pytest.raises(ValueError, match="unknown agent"):
build_explicit_graph(
task="bad graph",
agents=[
{"name": "collector", "instruction": "Collect"},
{"name": "reporter", "instruction": "Report"},
],
edges=[["collector", "missing"]],
output_agent="reporter",
)
with pytest.raises(ValueError, match="cyclic"):
build_explicit_graph(
task="bad graph",
agents=[
{"name": "a", "instruction": "A"},
{"name": "b", "instruction": "B"},
],
edges=[["a", "b"], ["b", "a"]],
output_agent="b",
)
with pytest.raises(ValueError, match="disconnected"):
build_explicit_graph(
task="bad graph",
agents=[
{"name": "collector", "instruction": "Collect"},
{"name": "reporter", "instruction": "Report"},
{"name": "orphan", "instruction": "Unused"},
],
edges=[["collector", "reporter"]],
output_agent="reporter",
)

View File

@ -0,0 +1,182 @@
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any
from beaver.coordinator import NodeRunResult, TeamRunResult
from beaver.tools import ToolContext
from beaver.tools.mcp.wrapper import MCPToolWrapper
def _tool_def(name: str) -> SimpleNamespace:
return SimpleNamespace(
name=name,
description=name,
inputSchema={"type": "object", "properties": {}},
)
def test_team_workflow_mcp_wrapper_bridges_to_current_team_runtime() -> None:
remote_calls: list[tuple[str, dict[str, Any]]] = []
captured: dict[str, Any] = {}
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
remote_calls.append((name, arguments))
raise AssertionError("team workflow bridge must not call MCP subprocess")
async def runner(graph, **kwargs: Any) -> TeamRunResult:
captured["graph"] = graph
captured["kwargs"] = kwargs
return TeamRunResult(
success=True,
summary="team done",
node_results=[
NodeRunResult("collect", True, "collected", run_id="run-collect"),
NodeRunResult("report", True, "reported", run_id="run-report"),
],
run_ids=["run-collect", "run-report"],
session_ids=["session:collect", "session:report"],
task_id=kwargs["parent_task_id"],
)
wrapper = MCPToolWrapper(
"local_team_workflow_mcp",
_tool_def("SequentialWorkflow"),
call_tool,
category="team_workflow",
kind="local",
)
context = ToolContext(
session_id="session-1",
services={
"task_id": "task-1",
"run_id": "run-root",
"agent_team_runner": runner,
},
metadata={"source": "websocket"},
)
result = asyncio.run(
wrapper.invoke(
{
"task": "finance report",
"agents": [
{"name": "collect", "instruction": "Collect official sources"},
{"name": "report", "instruction": "Write report"},
],
},
context,
)
)
payload = json.loads(result.content)
graph = captured["graph"]
assert remote_calls == []
assert result.success is True
assert result.tool_name == "mcp_local_team_workflow_mcp_SequentialWorkflow"
assert payload["success"] is True
assert payload["workflow"] == "SequentialWorkflow"
assert payload["summary"] == "team done"
assert payload["run_ids"] == ["run-collect", "run-report"]
assert captured["kwargs"]["parent_task_id"] == "task-1"
assert captured["kwargs"]["parent_session_id"] == "session-1"
assert captured["kwargs"]["parent_run_id"] == "run-root"
assert graph.strategy == "sequence"
assert {node.node_id: list(node.depends_on) for node in graph.nodes} == {
"collect": [],
"report": ["collect"],
}
def test_ordinary_mcp_wrapper_still_calls_remote_tool() -> None:
remote_calls: list[tuple[str, dict[str, Any]]] = []
async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
remote_calls.append((name, arguments))
return SimpleNamespace(content=[], structuredContent={"ok": True})
wrapper = MCPToolWrapper(
"local_web_mcp",
_tool_def("web_search"),
call_tool,
category="web",
kind="local",
)
result = asyncio.run(wrapper.invoke({"query": "beaver"}, ToolContext()))
assert result.success is True
assert remote_calls == [("web_search", {"query": "beaver"})]
def test_team_workflow_bridge_uses_team_service_without_injected_runner(monkeypatch) -> None:
captured: dict[str, Any] = {}
class FakeTeamService:
def __init__(self, loop: Any) -> None:
captured["loop"] = loop
async def run_team(self, graph, **kwargs: Any) -> TeamRunResult:
captured["graph"] = graph
captured["kwargs"] = kwargs
return TeamRunResult(
success=True,
summary="service team done",
node_results=[NodeRunResult("only", True, "ok", run_id="run-only")],
run_ids=["run-only"],
session_ids=["session:only"],
task_id=kwargs["parent_task_id"],
)
class FakeAgentLoop:
def __init__(self, *, profile: Any, loader: Any) -> None:
self.profile = profile
self.loader = loader
self.loaded = None
monkeypatch.setattr("beaver.engine.AgentLoop", FakeAgentLoop)
monkeypatch.setattr("beaver.services.team_service.TeamService", FakeTeamService)
wrapper = MCPToolWrapper(
"local_team_workflow_mcp",
_tool_def("ConcurrentWorkflow"),
call_tool=lambda _name, _arguments: None, # type: ignore[arg-type]
category="team_workflow",
kind="local",
)
parent_loop = SimpleNamespace(profile="profile", loader="loader")
context = ToolContext(
session_id="session-1",
services={
"task_id": "task-1",
"run_id": "run-root",
"agent_loop": parent_loop,
"loaded": SimpleNamespace(name="loaded"),
},
)
result = asyncio.run(
wrapper.invoke(
{
"task": "parallel work",
"agents": [{"name": "only", "instruction": "Do work"}],
},
context,
)
)
payload = json.loads(result.content)
assert result.success is True
assert payload["summary"] == "service team done"
assert captured["loop"].profile == "profile"
assert captured["loop"].loader == "loader"
assert captured["loop"].loaded.name == "loaded"
assert captured["kwargs"]["parent_task_id"] == "task-1"
assert captured["kwargs"]["parent_session_id"] == "session-1"
assert captured["kwargs"]["parent_run_id"] == "run-root"
assert captured["kwargs"]["allow_candidate_generation"] is False
assert captured["graph"].strategy == "parallel"

View File

@ -146,11 +146,6 @@ export default function NotificationDetailPage() {
isThinking={submitting} isThinking={submitting}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
messageViewportRef={viewportRef} messageViewportRef={viewportRef}
processRuns={[]}
processEvents={[]}
processArtifacts={[]}
selectedRunId={null}
onSelectRun={() => {}}
onFeedback={() => {}} onFeedback={() => {}}
onRequestRevision={() => {}} onRequestRevision={() => {}}
/> />

View File

@ -77,7 +77,6 @@ export default function ChatPage() {
processRuns, processRuns,
processEvents, processEvents,
processArtifacts, processArtifacts,
selectedRunId,
setSessionId, setSessionId,
setMessages, setMessages,
addMessage, addMessage,
@ -128,12 +127,6 @@ export default function ChatPage() {
[processEvents, sessionRunIds] [processEvents, sessionRunIds]
); );
const sessionProcessArtifacts = useMemo(
() => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)),
[processArtifacts, sessionRunIds]
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const activeTaskTimelineView = useMemo( const activeTaskTimelineView = useMemo(
() => () =>
buildTaskTimelineView({ buildTaskTimelineView({
@ -710,11 +703,6 @@ export default function ChatPage() {
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')} isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef} messageViewportRef={messageViewportRef}
processRuns={sessionProcessRuns}
processEvents={sessionProcessEvents}
processArtifacts={sessionProcessArtifacts}
selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onFeedback={handleFeedback} onFeedback={handleFeedback}
onRequestRevision={handleRequestRevision} onRequestRevision={handleRequestRevision}
/> />
@ -881,6 +869,8 @@ export default function ChatPage() {
{activeTaskDetail ? ( {activeTaskDetail ? (
<CurrentSessionProgressSidebar <CurrentSessionProgressSidebar
task={activeTaskDetail}
process={activeTaskTimelineView?.process ?? null}
cards={activeTaskTimelineView?.cards ?? []} cards={activeTaskTimelineView?.cards ?? []}
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')} isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
/> />

View File

@ -1,20 +1,19 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react'; import { AlertCircle, ArrowLeft, Loader2 } from 'lucide-react';
import { import {
TaskExecutionWorkspace,
TaskLiveHeader, TaskLiveHeader,
TaskSideRail,
TaskTimeline,
type TaskFeedbackItem, type TaskFeedbackItem,
type TaskFeedbackType, type TaskFeedbackType,
} from '@/components/task-detail'; } from '@/components/task-detail';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api'; import { getBackendTask, submitChatFeedback } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
@ -27,7 +26,6 @@ const TASK_RESULT_REVIEW_ID = 'task-result-review';
export default function TaskDetailPage() { export default function TaskDetailPage() {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const router = useRouter();
const params = useParams<{ taskId: string }>(); const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? ''); const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
const processRuns = useChatStore((state) => state.processRuns); const processRuns = useChatStore((state) => state.processRuns);
@ -120,18 +118,6 @@ export default function TaskDetailPage() {
} }
}; };
const deleteCurrentBackendTask = async () => {
if (!backendTask) return;
const title = backendTask.short_title || backendTask.description || backendTask.goal || backendTask.task_id;
if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) {
return;
}
await runAction('delete-backend-task', async () => {
await deleteBackendTask(backendTask.task_id);
router.push('/tasks');
});
};
if (backendTask) { if (backendTask) {
const feedbackItems = backendTask.feedback || []; const feedbackItems = backendTask.feedback || [];
@ -139,21 +125,7 @@ export default function TaskDetailPage() {
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} /> <TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
<main className="mx-auto grid min-w-0 max-w-7xl gap-6 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_360px]"> <main className="mx-auto min-w-0 max-w-[1720px] space-y-5 px-4 py-1 sm:px-6 lg:px-8">
<div className="min-w-0 space-y-4">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-11 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>
{actionError ? ( {actionError ? (
<Card className="border-destructive"> <Card className="border-destructive">
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive"> <CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
@ -163,7 +135,9 @@ export default function TaskDetailPage() {
</Card> </Card>
) : null} ) : null}
<TaskTimeline <TaskExecutionWorkspace
task={backendTask}
process={timelineView?.process ?? { runs: [], events: [], artifacts: [] }}
cards={timelineCards} cards={timelineCards}
isLive={isTaskLive && wsStatus === 'connected'} isLive={isTaskLive && wsStatus === 'connected'}
reviewTargetId={TASK_RESULT_REVIEW_ID} reviewTargetId={TASK_RESULT_REVIEW_ID}
@ -190,14 +164,6 @@ export default function TaskDetailPage() {
}), }),
}} }}
/> />
</div>
<TaskSideRail
task={backendTask}
runs={timelineView?.process.runs ?? []}
artifacts={timelineView?.process.artifacts ?? []}
cards={timelineCards}
/>
</main> </main>
</div> </div>
); );

View File

@ -10,7 +10,7 @@ export function AppShell({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<Header /> <Header />
<main className="pt-16"> <main className="pt-14">
<AuthGuard> <AuthGuard>
<AppRuntimeBridge /> <AppRuntimeBridge />
{children} {children}

View File

@ -131,8 +131,8 @@ const Header = () => {
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={compact ? () => setMobileMenuOpen(false) : undefined} onClick={compact ? () => setMobileMenuOpen(false) : undefined}
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${ className={`flex shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4' compact ? 'h-11 justify-start rounded-lg border border-transparent bg-background px-4' : 'h-10 px-3.5'
} ${ } ${
isActive isActive
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
@ -151,11 +151,11 @@ const Header = () => {
<> <>
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur"> <header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between gap-3"> <div className="flex h-14 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button <button
type="button" type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] min-[1800px]:hidden" className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] xl:hidden"
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')} aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
aria-controls="app-primary-mobile-nav" aria-controls="app-primary-mobile-nav"
@ -163,14 +163,17 @@ const Header = () => {
> >
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button> </button>
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex"> <Link href="/" className="hidden h-11 shrink-0 items-center gap-2 min-[360px]:flex">
<span className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-[#1D1715] bg-white">
<Bot className="h-5 w-5 text-[#1D1715]" />
</span>
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]"> <span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
Beaver Beaver
</span> </span>
</Link> </Link>
</div> </div>
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] min-[1800px]:flex"> <nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] xl:flex">
{renderNavLinks(false)} {renderNavLinks(false)}
</nav> </nav>
@ -245,14 +248,14 @@ const Header = () => {
<> <>
<button <button
type="button" type="button"
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 min-[1800px]:hidden" className="fixed inset-x-0 bottom-0 top-14 z-40 bg-black/40 xl:hidden"
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')} aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
<nav <nav
id="app-primary-mobile-nav" id="app-primary-mobile-nav"
aria-label={pickAppText(locale, '主导航', 'Primary navigation')} aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 min-[1800px]:hidden" className="fixed bottom-0 left-0 top-14 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 xl:hidden"
> >
<div className="min-h-full bg-background px-4 py-5"> <div className="min-h-full bg-background px-4 py-5">
<div className="grid gap-2 bg-background"> <div className="grid gap-2 bg-background">

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; import type { ChatMessage } from '@/types';
import { MessageList } from '@/components/chat-workbench/MessageList'; import { MessageList } from '@/components/chat-workbench/MessageList';
export function ChatWorkbench({ export function ChatWorkbench({
@ -10,11 +10,6 @@ export function ChatWorkbench({
isThinking, isThinking,
messagesEndRef, messagesEndRef,
messageViewportRef, messageViewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onFeedback, onFeedback,
onRequestRevision, onRequestRevision,
}: { }: {
@ -22,11 +17,6 @@ export function ChatWorkbench({
isThinking: boolean; isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>; messagesEndRef: React.RefObject<HTMLDivElement>;
messageViewportRef: React.RefObject<HTMLDivElement>; messageViewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void; onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void; onRequestRevision: (runId: string) => void;
}) { }) {
@ -37,11 +27,6 @@ export function ChatWorkbench({
isThinking={isThinking} isThinking={isThinking}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef} viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onFeedback={onFeedback} onFeedback={onFeedback}
onRequestRevision={onRequestRevision} onRequestRevision={onRequestRevision}
/> />

View File

@ -1,24 +1,245 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Activity, PanelRightOpen, X } from 'lucide-react'; import { Activity, CheckCircle2, ChevronDown, Circle, FileText, LoaderCircle, PanelRightOpen, Sparkles, TerminalSquare, Users, X } from 'lucide-react';
import { TaskTimeline } from '@/components/task-detail'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskTimelineCard } from '@/types'; import {
buildTaskUiModel,
taskUiStatusClass,
taskUiStatusLabel,
type TaskUiModel,
type TaskUiStatus,
} from '@/lib/task-ui-model';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { BackendTask, SessionProcessProjection, TaskTimelineCard } from '@/types';
function StatusBadge({ status }: { status: TaskUiStatus }) {
const { locale } = useAppI18n();
return (
<Badge variant="outline" className={`h-7 rounded-full px-2.5 text-[11px] ${taskUiStatusClass(status)}`}>
{taskUiStatusLabel(status, locale)}
</Badge>
);
}
function ProgressCard({
icon,
title,
label,
status,
children,
}: {
icon: React.ReactNode;
title: string;
label: string;
status: TaskUiStatus;
children: React.ReactNode;
}) {
const { locale } = useAppI18n();
const [open, setOpen] = React.useState(true);
return (
<section className="min-w-0 rounded-lg border border-[#E6E1DE] bg-white shadow-[0_6px_18px_rgba(31,24,20,0.04)]">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="flex min-h-[52px] w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left"
aria-expanded={open}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#F1EFEE] text-[#615854]">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[#1D1715]">{title}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{label}</span>
</span>
<StatusBadge status={status} />
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div className="border-t border-[#ECE8E5] px-4 py-2.5">
<div className="min-w-0">{children}</div>
<button
type="button"
className="mt-3 flex h-9 w-full items-center justify-center rounded-md border border-[#E6E1DE] bg-[#FBFAF9] text-xs font-medium text-[#615854] hover:bg-[#F4F1EF]"
>
{pickAppText(locale, '详情', 'Details')}
</button>
</div>
) : null}
</section>
);
}
function SummarySection({ model }: { model: TaskUiModel }) {
return (
<p className={`line-clamp-2 text-sm leading-5 text-muted-foreground ${containedPreservedLongTextClass}`}>
{model.summary.summary}
</p>
);
}
function SkillSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
if (model.skills.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无 Skill 选择', 'No skill selected yet')}</p>;
}
const primary = model.skills[0];
return (
<div className="min-w-0">
<div className={`line-clamp-1 text-sm font-medium text-[#1D1715] ${containedLongTextClass}`}>{primary.name}</div>
{primary.createdAt ? <div className="mt-1 text-xs text-muted-foreground">{formatTaskRuntimeTime(primary.createdAt, locale)}</div> : null}
{primary.summary ? (
<p className={`mt-1 line-clamp-1 text-xs leading-5 text-muted-foreground ${containedLongTextClass}`}>{primary.summary}</p>
) : null}
</div>
);
}
function ToolsSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const attempts = model.attempts.filter((attempt) => attempt.tools.length > 0);
if (attempts.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无工具调用', 'No tool calls yet')}</p>;
}
return (
<div className="space-y-3">
{attempts.map((attempt) => (
<div key={attempt.id} className="min-w-0">
<div className="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{attempt.title}</span>
<span>{attempt.tools.length} calls</span>
</div>
<div className="space-y-2">
{attempt.tools.map((tool) => (
<div key={tool.id} className="grid min-w-0 grid-cols-[minmax(0,1fr)_76px_46px] items-center gap-2">
<span className={`text-sm text-[#1D1715] ${containedLongTextClass}`}>
{tool.toolName}
</span>
<span className={`flex items-center gap-1 text-xs ${tool.status === 'done' ? 'text-[#22733A]' : tool.status === 'running' ? 'text-[#B26A00]' : tool.status === 'error' ? 'text-[#9D3D2F]' : 'text-muted-foreground'}`}>
{tool.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5" /> : tool.status === 'running' ? <LoaderCircle className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}
{taskUiStatusLabel(tool.status, locale)}
</span>
<span className="text-right text-xs text-muted-foreground">{formatSidebarToolDuration(tool, locale)}</span>
</div>
))}
</div>
</div>
))}
</div>
);
}
function formatSidebarToolDuration(tool: TaskUiModel['tools'][number], locale: string): string {
if (typeof tool.durationMs === 'number') {
return formatTaskRuntimeDuration(tool.durationMs, locale);
}
if (tool.status === 'running' && tool.createdAt) {
const startMs = new Date(tool.createdAt).getTime();
if (!Number.isNaN(startMs)) {
return formatTaskRuntimeDuration(Date.now() - startMs, locale);
}
}
return '-';
}
function flattenAgents(model: TaskUiModel) {
const output: Array<{ id: string; name: string; status: TaskUiStatus; depth: number }> = [];
const visit = (node: TaskUiModel['agentTree'][number], depth: number) => {
output.push({ id: node.runId, name: node.title || node.name, status: node.status, depth });
node.children.forEach((child) => visit(child, depth + 1));
};
model.agentTree.forEach((node) => visit(node, 0));
return output;
}
function AgentSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const rows = flattenAgents(model).slice(0, 6);
if (!model.team.hasTeam) {
return (
<div className="rounded-md border border-[#E6E1DE] bg-[#FBFAF9] px-3 py-2 text-sm text-muted-foreground">
{pickAppText(locale, '本轮为 Main Agent 单线程执行,未启动 Agent Team。', 'This run uses the Main Agent only; no Agent Team was started.')}
</div>
);
}
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, 'Agent Team 已启动,等待节点数据', 'Agent Team started; waiting for node data')}</p>;
}
return (
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-[#615854]">
<Users className="h-3.5 w-3.5" />
<span>{rows[0]?.name}</span>
</div>
<div className="ml-4 mt-2 space-y-2 border-l border-[#D8D2CE] pl-4">
{rows.map((node) => (
<div key={node.id} className="grid min-w-0 grid-cols-[14px_minmax(0,1fr)_74px] items-center gap-2" style={{ paddingLeft: `${Math.min(node.depth, 3) * 8}px` }}>
<span className="h-px w-3 bg-[#D8D2CE]" />
<span className={`text-xs text-[#1D1715] ${containedLongTextClass}`}>{node.name}</span>
<span className={`flex items-center gap-1 text-xs ${node.status === 'done' ? 'text-[#22733A]' : node.status === 'running' ? 'text-[#B26A00]' : 'text-muted-foreground'}`}>
{node.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5" /> : node.status === 'running' ? <LoaderCircle className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}
{taskUiStatusLabel(node.status, locale)}
</span>
</div>
))}
</div>
</div>
);
}
function statusForSummary(task: BackendTask): TaskUiStatus {
if (task.status === 'awaiting_acceptance' || task.status === 'closed') return 'done';
if (task.status === 'running') return 'running';
return 'waiting';
}
function primarySkillName(model: TaskUiModel) {
return model.skills[0]?.name || '';
}
function hasExecutionStructure(model: TaskUiModel): boolean {
return model.team.hasTeam || model.agentTree.length > 0;
}
function toolStatus(model: TaskUiModel): TaskUiStatus {
if (model.tools.some((tool) => tool.status === 'running')) return 'running';
if (model.tools.some((tool) => tool.status === 'error')) return 'error';
return model.tools.length ? 'done' : 'waiting';
}
function agentStatus(model: TaskUiModel): TaskUiStatus {
if (model.agentTree.some((node) => node.status === 'running' || node.children.some((child) => child.status === 'running'))) return 'running';
if (!model.team.hasTeam) return 'waiting';
if (model.agentTree.some((node) => node.status === 'error' || node.children.some((child) => child.status === 'error'))) return 'error';
return model.agentTree.length ? 'done' : model.team.status;
}
function ProgressPanel({ function ProgressPanel({
task,
process,
cards, cards,
isLive, isLive,
onClose, onClose,
}: { }: {
task: BackendTask | null;
process: SessionProcessProjection | null;
cards: TaskTimelineCard[]; cards: TaskTimelineCard[];
isLive: boolean; isLive: boolean;
onClose?: () => void; onClose?: () => void;
}) { }) {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const model = task
? buildTaskUiModel({
task,
process: process ?? { runs: [], events: [], artifacts: [] },
cards,
locale,
})
: null;
return ( return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-[#FBFAF9]"> <div className="flex h-full min-w-0 flex-col overflow-hidden bg-[#FBFAF9]">
@ -48,9 +269,58 @@ function ProgressPanel({
) : null} ) : null}
</div> </div>
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden px-4 py-4"> <ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden px-4 py-2.5">
<div className="min-w-0 max-w-full pb-6"> <div className="min-w-0 max-w-full pb-6">
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} /> {model ? (
<div className="space-y-2.5">
<ProgressCard
icon={<FileText className="h-4 w-4" />}
title={pickAppText(locale, '任务摘要', 'Task summary')}
label={model.summary.title}
status={task ? statusForSummary(task) : 'waiting'}
>
<SummarySection model={model} />
</ProgressCard>
{model.skills.length > 0 ? (
<ProgressCard
icon={<Sparkles className="h-4 w-4" />}
title={pickAppText(locale, 'Skill 选择', 'Skill selection')}
label={primarySkillName(model)}
status={model.skills[0]?.status || 'waiting'}
>
<SkillSection model={model} />
</ProgressCard>
) : null}
{model.tools.length > 0 ? (
<ProgressCard
icon={<TerminalSquare className="h-4 w-4" />}
title={pickAppText(locale, '工具调用', 'Tool calls')}
label={pickAppText(locale, `${model.tools.length} 个工具调用`, `${model.tools.length} tool calls`)}
status={toolStatus(model)}
>
<ToolsSection model={model} />
</ProgressCard>
) : null}
{hasExecutionStructure(model) ? (
<ProgressCard
icon={<Users className="h-4 w-4" />}
title={pickAppText(locale, '执行结构', 'Execution structure')}
label={
model.team.hasTeam
? pickAppText(locale, `Agent Team · ${model.team.outcome}`, `Agent Team · ${model.team.outcome}`)
: pickAppText(locale, 'Agent run', 'Agent run')
}
status={agentStatus(model)}
>
<AgentSection model={model} />
</ProgressCard>
) : null}
</div>
) : (
<div className="rounded-lg border border-dashed border-[#DED8D4] bg-white px-4 py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '当前会话暂无运行任务', 'No running task in this session')}
</div>
)}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
@ -58,9 +328,13 @@ function ProgressPanel({
} }
export function CurrentSessionProgressSidebar({ export function CurrentSessionProgressSidebar({
task,
process,
cards, cards,
isLive, isLive,
}: { }: {
task: BackendTask | null;
process: SessionProcessProjection | null;
cards: TaskTimelineCard[]; cards: TaskTimelineCard[];
isLive: boolean; isLive: boolean;
}) { }) {
@ -70,7 +344,7 @@ export function CurrentSessionProgressSidebar({
return ( return (
<> <>
<aside className="hidden h-full w-[380px] min-w-0 shrink-0 overflow-hidden border-l border-[#E6E1DE] xl:flex"> <aside className="hidden h-full w-[380px] min-w-0 shrink-0 overflow-hidden border-l border-[#E6E1DE] xl:flex">
<ProgressPanel cards={cards} isLive={isLive} /> <ProgressPanel task={task} process={process} cards={cards} isLive={isLive} />
</aside> </aside>
<button <button
@ -91,7 +365,7 @@ export function CurrentSessionProgressSidebar({
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')} aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
/> />
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] min-w-0 overflow-hidden border-l border-[#E6E1DE] shadow-2xl"> <div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] min-w-0 overflow-hidden border-l border-[#E6E1DE] shadow-2xl">
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} /> <ProgressPanel task={task} process={process} cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
</div> </div>
</div> </div>
) : null} ) : null}

View File

@ -4,10 +4,9 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react'; import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; import type { ChatMessage } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api'; import { getAccessToken, getFileUrl } from '@/lib/api';
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages'; import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent'; import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
@ -268,14 +267,6 @@ function MessageBubble({
); );
} }
type AgentTeamGroup = {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
startedAt: string;
};
const TERMINAL_RUN_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
function shouldHideSystemAgentMessage(message: ChatMessage): boolean { function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
if (message.role !== 'assistant' || typeof message.content !== 'string') { if (message.role !== 'assistant' || typeof message.content !== 'string') {
return false; return false;
@ -299,72 +290,11 @@ function shouldHideMessage(message: ChatMessage): boolean {
return !shouldDisplayChatMessage(message); return !shouldDisplayChatMessage(message);
} }
function parseTimelineTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
const groups = new Map<string, AgentTeamGroup>();
for (const run of processRuns) {
if (run.actor_type !== 'agent') {
continue;
}
let root = run;
const seen = new Set<string>([run.run_id]);
let parentId = run.parent_run_id ?? null;
while (parentId) {
const parent = runMap.get(parentId);
if (!parent || seen.has(parent.run_id)) {
break;
}
root = parent;
seen.add(parent.run_id);
parentId = parent.parent_run_id ?? null;
}
const existing = groups.get(root.run_id);
if (existing) {
existing.memberRuns.push(run);
continue;
}
groups.set(root.run_id, {
rootRun: root,
memberRuns: [run],
startedAt: root.started_at || run.started_at,
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
const at = parseTimelineTime(a.started_at) ?? 0;
const bt = parseTimelineTime(b.started_at) ?? 0;
return at - bt;
}),
}))
.sort((a, b) => {
const at = parseTimelineTime(a.startedAt) ?? 0;
const bt = parseTimelineTime(b.startedAt) ?? 0;
return at - bt;
});
}
export function MessageList({ export function MessageList({
messages, messages,
isThinking, isThinking,
messagesEndRef, messagesEndRef,
viewportRef, viewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onFeedback, onFeedback,
onRequestRevision, onRequestRevision,
}: { }: {
@ -372,11 +302,6 @@ export function MessageList({
isThinking: boolean; isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>; messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>; viewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void; onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void; onRequestRevision: (runId: string) => void;
}) { }) {
@ -385,37 +310,6 @@ export function MessageList({
() => messages.filter((message) => !shouldHideMessage(message)), () => messages.filter((message) => !shouldHideMessage(message)),
[messages] [messages]
); );
const teamGroups = React.useMemo(
() =>
buildAgentTeamGroups(processRuns).filter((group) =>
group.memberRuns.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))
),
[processRuns]
);
const timelineItems = React.useMemo(() => {
const messageItems = visibleMessages.map((message, index) => ({
kind: 'message' as const,
key: `${message.role}:${message.timestamp || index}:${index}`,
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
order: index,
message,
messageIndex: index,
}));
const teamItems = teamGroups.map((group, index) => ({
kind: 'team' as const,
key: `team:${group.rootRun.run_id}`,
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + visibleMessages.length + index,
order: visibleMessages.length + index,
group,
}));
return [...messageItems, ...teamItems].sort((a, b) => {
if (a.sortTime !== b.sortTime) {
return a.sortTime - b.sortTime;
}
return a.order - b.order;
});
}, [teamGroups, visibleMessages]);
const taskCardMessageIndexes = React.useMemo( const taskCardMessageIndexes = React.useMemo(
() => getTaskCardMessageIndexes(visibleMessages), () => getTaskCardMessageIndexes(visibleMessages),
[visibleMessages] [visibleMessages]
@ -439,7 +333,7 @@ export function MessageList({
return ( return (
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}> <ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10"> <div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && ( {visibleMessages.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" /> <Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium text-foreground">Beaver</p> <p className="text-lg font-medium text-foreground">Beaver</p>
@ -447,28 +341,16 @@ export function MessageList({
</div> </div>
)} )}
{timelineItems.map((item) => {visibleMessages.map((message, index) => (
item.kind === 'message' ? (
<MessageBubble <MessageBubble
key={item.key} key={`${message.role}:${message.timestamp || index}:${index}`}
message={item.message} message={message}
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)} showTaskCard={taskCardMessageIndexes.has(index)}
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex} canSendFeedback={index === latestFeedbackMessageIndex}
onFeedback={onFeedback} onFeedback={onFeedback}
onRequestRevision={onRequestRevision} onRequestRevision={onRequestRevision}
/> />
) : ( ))}
<AgentTeamBlock
key={item.key}
rootRun={item.group.rootRun}
memberRuns={item.group.memberRuns}
events={processEvents}
artifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
/>
)
)}
{isThinking && ( {isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1"> <div className="flex items-center gap-2 text-muted-foreground px-1">

View File

@ -0,0 +1,638 @@
'use client';
import React from 'react';
import {
BarChart3,
CheckCircle2,
ChevronDown,
Clock3,
Database,
Download,
Eye,
FileImage,
FileJson,
FileText,
Globe2,
Grid2X2,
ListFilter,
Network,
PackageOpen,
RefreshCw,
ShieldCheck,
Table2,
UserRound,
} from 'lucide-react';
import type { TaskFeedbackType } from '@/components/task-detail/TaskAcceptanceCard';
import type { TaskResultAcceptance } from '@/components/task-detail/TaskTimelineCard';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import {
buildTaskUiModel,
taskUiStatusClass,
taskUiStatusLabel,
type TaskUiAgentNode,
type TaskUiArtifact,
type TaskUiAttempt,
type TaskUiModel,
type TaskUiStatus,
type TaskUiStep,
} from '@/lib/task-ui-model';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { BackendTask, SessionProcessProjection, TaskTimelineCard } from '@/types';
type Props = {
task: BackendTask;
process: SessionProcessProjection;
cards: TaskTimelineCard[];
isLive: boolean;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
};
function StatusBadge({ status, compact = false }: { status: TaskUiStatus; compact?: boolean }) {
const { locale } = useAppI18n();
return (
<Badge
variant="outline"
className={`${compact ? 'h-6 px-2 text-[11px]' : 'h-7 px-3 text-xs'} rounded-full font-medium ${taskUiStatusClass(status)}`}
>
{taskUiStatusLabel(status, locale)}
</Badge>
);
}
function Section({
title,
children,
action,
className = '',
}: {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
className?: string;
}) {
return (
<section className={`min-w-0 rounded-lg border border-[#E6E1DE] bg-white ${className}`}>
<div className="flex min-h-[50px] items-center justify-between gap-3 px-5 py-2.5">
<h2 className="text-base font-semibold text-[#1D1715]">{title}</h2>
{action}
</div>
{children}
</section>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[#DED8D4] bg-[#FBFAF9] px-4 py-8 text-center text-sm text-muted-foreground">
<PackageOpen className="h-5 w-5 text-[#8D8782]" />
{children}
</div>
);
}
function iconForStep(kind: TaskUiStep['kind']) {
if (kind === 'skill') return Grid2X2;
if (kind === 'tool') return Clock3;
if (kind === 'agent') return Network;
if (kind === 'artifact') return FileText;
if (kind === 'result') return BarChart3;
return FileText;
}
function statusDotClass(status: TaskUiStatus) {
if (status === 'done') return 'bg-[#22733A]';
if (status === 'running') return 'bg-[#C47B00]';
if (status === 'error') return 'bg-[#9D3D2F]';
if (status === 'cancelled') return 'bg-[#756A64]';
return 'bg-[#8D8782]';
}
function ExecutionFlow({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const steps = model.steps.slice(0, 6);
const columnClass = steps.length >= 6 ? 'grid-cols-6' : steps.length >= 4 ? 'grid-cols-4' : steps.length >= 2 ? 'grid-cols-2' : 'grid-cols-1';
return (
<Section
title="任务执行流程"
action={
<Button variant="outline" size="sm" className="h-9 rounded-lg border-[#E6E1DE] bg-white text-xs">
</Button>
}
>
<div className="px-5 pb-4">
<div className={`relative grid gap-5 ${columnClass}`}>
{steps.length > 1 ? <div className="absolute left-8 right-8 top-[17px] h-px bg-[#CFC8C3]" /> : null}
{steps.map((step) => {
const Icon = iconForStep(step.kind);
return (
<div key={step.id} className="relative min-w-0">
<div className="relative z-10 flex h-10 items-center">
<span className={`flex h-5 w-5 items-center justify-center rounded-full ${statusDotClass(step.status)}`}>
{step.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5 text-white" /> : <span className="h-1.5 w-1.5 rounded-full bg-white" />}
</span>
<span className="ml-5 flex h-10 w-10 items-center justify-center rounded-full border border-[#D8D2CE] bg-[#F8F6F4]">
<Icon className="h-5 w-5 text-[#1D1715]" />
</span>
</div>
<h3 className={`text-sm font-semibold text-[#1D1715] ${containedLongTextClass}`}>{step.title}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2">
{step.createdAt ? <span className="text-xs text-muted-foreground">{formatTaskRuntimeTime(step.createdAt, locale)}</span> : null}
<StatusBadge status={step.status} compact />
</div>
{step.summary ? (
<p className={`mt-1 line-clamp-4 text-xs leading-[17px] text-[#4F4642] ${containedPreservedLongTextClass}`}>{step.summary}</p>
) : null}
</div>
);
})}
</div>
</div>
</Section>
);
}
function progressColor(status: TaskUiStatus) {
if (status === 'done') return '#137333';
if (status === 'running') return '#D48500';
if (status === 'error') return '#9D3D2F';
if (status === 'cancelled') return '#756A64';
return '#E3DFDC';
}
function AgentCard({ agent, root = false }: { agent: TaskUiAgentNode; root?: boolean }) {
return (
<div className={`relative rounded-lg border border-[#E1DCD8] bg-white px-3 py-3 ${root ? 'h-[68px] w-60 shadow-[0_2px_8px_rgba(31,24,20,0.05)]' : 'h-[90px] w-[150px]'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<UserRound className="h-4 w-4 shrink-0 text-[#1D1715]" />
<span className="min-w-0 truncate text-[13px] font-semibold leading-5">{agent.title || agent.name}</span>
</div>
<div className={root ? '' : 'absolute right-3 top-8'}>
<StatusBadge status={agent.status} compact />
</div>
</div>
<div className={root ? 'mt-3 h-1.5 rounded-full bg-[#ECE8E5]' : 'mt-8 h-1.5 rounded-full bg-[#ECE8E5]'}>
<div className="h-full rounded-full" style={{ width: `${agent.progress}%`, backgroundColor: progressColor(agent.status) }} />
</div>
<div className="mt-2 text-right text-xs text-[#4F4642]">{agent.progress}%</div>
</div>
);
}
function AgentDAG({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const roots = model.agentTree;
const root = roots.find((node) => node.children.length > 0) ?? roots[0];
const children = root?.children.length ? root.children : roots.filter((node) => node.runId !== root?.runId);
const visibleChildren = children.slice(0, 5);
if (!model.team.hasTeam) {
return null;
}
return (
<Section
title={pickAppText(locale, 'Agent Team 执行图', 'Agent Team execution graph')}
action={
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
{model.team.outcome}
</Badge>
<StatusBadge status={model.team.status} compact />
</div>
}
>
<div className="px-5 pb-5">
{roots.length === 0 ? (
<EmptyState>{pickAppText(locale, '暂无 Agent Team 数据', 'No Agent Team data yet')}</EmptyState>
) : (
<div className="relative min-h-[196px]">
{visibleChildren.length > 0 ? (
<>
<div className="absolute left-1/2 top-[70px] h-10 w-px -translate-x-1/2 bg-[#1D1715]" />
<div className="absolute left-[9%] right-[9%] top-[110px] h-px bg-[#1D1715]" />
{visibleChildren.map((child, index) => (
<div
key={child.runId}
className="absolute top-[110px] h-7 w-px bg-[#1D1715]"
style={{ left: `${9 + index * (82 / Math.max(visibleChildren.length - 1, 1))}%` }}
/>
))}
</>
) : null}
<div className="flex justify-center">
<AgentCard agent={root} root />
</div>
{visibleChildren.length > 0 ? (
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between gap-4 px-6">
{visibleChildren.map((agent) => (
<AgentCard key={agent.runId} agent={agent} />
))}
</div>
) : null}
</div>
)}
</div>
</Section>
);
}
function RunPath({
model,
selectedAttemptId,
onSelectAttempt,
}: {
model: TaskUiModel;
selectedAttemptId: string | null;
onSelectAttempt: (attemptId: string) => void;
}) {
const { locale } = useAppI18n();
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => new Set());
const attempts = model.attempts.filter((attempt) => attempt.runs.length > 0 || attempt.tools.length > 0 || attempt.result);
if (attempts.length === 0) return null;
return (
<Section
title={pickAppText(locale, '运行路径与结果版本', 'Run path and result versions')}
action={
<Badge variant="outline" className="h-7 rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
{attempts.length} runs
</Badge>
}
>
<div className="space-y-3 border-t border-[#ECE8E5] p-4">
{attempts.map((attempt) => (
<div
key={attempt.id}
onClick={() => onSelectAttempt(attempt.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelectAttempt(attempt.id);
}
}}
role="group"
tabIndex={0}
aria-label={pickAppText(locale, `选择${attempt.title}`, `Select ${attempt.title}`)}
className={`rounded-lg border p-4 transition-colors ${
selectedAttemptId === attempt.id
? 'border-[#1D1715] bg-white shadow-[0_6px_18px_rgba(31,24,20,0.06)]'
: 'border-[#E1DCD8] bg-[#FBFAF9]'
} cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1D1715] focus-visible:ring-offset-2`}
>
<div className="flex w-full min-w-0 items-center justify-between gap-3 text-left">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-[#1D1715]">{attempt.title}</h3>
<StatusBadge status={attempt.status} compact />
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatTaskRuntimeTime(attempt.startedAt, locale)}
{attempt.finishedAt ? ` · ${formatAttemptDuration(attempt.startedAt, attempt.finishedAt, locale)}` : ''}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{attempt.tools.length} tools
</div>
</div>
{attempt.runs.length > 0 ? (
<div className="mt-3 flex min-w-0 flex-wrap items-center gap-2">
{attempt.runs.map((run, index) => (
<React.Fragment key={run.runId}>
{index > 0 ? <span className="text-xs text-[#B8AEA8]"></span> : null}
<span className="inline-flex max-w-[220px] items-center gap-1.5 rounded-full border border-[#E1DCD8] bg-white px-2.5 py-1 text-xs text-[#4F4642]">
<span className={`h-1.5 w-1.5 rounded-full ${run.status === 'done' ? 'bg-[#22733A]' : run.status === 'error' ? 'bg-[#9D3D2F]' : run.status === 'running' ? 'bg-[#C47B00]' : 'bg-[#8D8782]'}`} />
<span className="truncate">{run.actorName || run.title}</span>
</span>
</React.Fragment>
))}
</div>
) : null}
<button
type="button"
onClick={() => {
onSelectAttempt(attempt.id);
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(attempt.id)) next.delete(attempt.id);
else next.add(attempt.id);
return next;
});
}}
className="mt-3 flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[#615854] hover:bg-[#F4F1EF]"
aria-expanded={expandedIds.has(attempt.id)}
>
{expandedIds.has(attempt.id)
? pickAppText(locale, '收起结果', 'Collapse result')
: pickAppText(locale, '展开结果', 'Expand result')}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${expandedIds.has(attempt.id) ? 'rotate-180' : ''}`} />
</button>
{attempt.result && expandedIds.has(attempt.id) ? (
<div className="mt-3 rounded-md border border-[#E6E1DE] bg-white p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-[#615854]">
{pickAppText(locale, '本次结果', 'Attempt result')}
</span>
<StatusBadge status={attempt.result.status} compact />
</div>
<p className={`line-clamp-3 text-xs leading-5 text-[#4F4642] ${containedPreservedLongTextClass}`}>
{attempt.result.summary || attempt.result.title}
</p>
</div>
) : null}
</div>
))}
</div>
</Section>
);
}
function formatAttemptDuration(startedAt: string, finishedAt: string, locale: string): string {
const startMs = new Date(startedAt).getTime();
const finishMs = new Date(finishedAt).getTime();
if (Number.isNaN(startMs) || Number.isNaN(finishMs) || finishMs < startMs) return '-';
return formatTaskRuntimeDuration(finishMs - startMs, locale);
}
function toolsForAttempt(model: TaskUiModel, selectedAttemptId: string | null): TaskUiAttempt {
return (
model.attempts.find((attempt) => attempt.id === selectedAttemptId) ??
model.attempts.at(-1) ??
{
id: 'all',
index: 1,
title: 'Agent',
status: 'waiting',
startedAt: '',
runs: [],
tools: model.tools,
}
);
}
function ToolCalls({ model, selectedAttemptId }: { model: TaskUiModel; selectedAttemptId: string | null }) {
const { locale } = useAppI18n();
const selectedAttempt = toolsForAttempt(model, selectedAttemptId);
const agents = Array.from(new Set(selectedAttempt.tools.map((tool) => tool.actorName || 'Agent')));
const [selectedAgent, setSelectedAgent] = React.useState<string | null>(null);
const activeAgent = selectedAgent && agents.includes(selectedAgent) ? selectedAgent : agents[0] ?? 'Agent';
const visibleTools = selectedAttempt.tools.filter((tool) => (tool.actorName || 'Agent') === activeAgent);
return (
<Section
title={pickAppText(locale, '运行摘要', 'Run summary')}
action={
<div className="flex gap-2">
<Badge variant="outline" className="h-7 rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
{selectedAttempt.title}
</Badge>
</div>
}
>
{selectedAttempt.tools.length === 0 ? (
<div className="border-t border-[#ECE8E5] p-5">
<EmptyState>{pickAppText(locale, '暂无工具调用', 'No tool calls yet')}</EmptyState>
</div>
) : (
<div className="grid grid-cols-[170px_minmax(0,1fr)] border-t border-[#ECE8E5]">
<aside className="border-r border-[#ECE8E5] bg-[#FBFAF9] p-4">
{agents.map((name) => {
const count = selectedAttempt.tools.filter((tool) => (tool.actorName || 'Agent') === name).length;
return (
<button
key={name}
type="button"
onClick={() => setSelectedAgent(name)}
className={`flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm ${
activeAgent === name ? 'bg-white text-[#1D1715] ring-1 ring-[#E1DCD8]' : 'text-[#615854] hover:bg-white'
}`}
>
<Network className="h-3.5 w-3.5 text-[#615854]" />
<span className="min-w-0">
<span className="block truncate">{model.team.hasTeam ? name : 'Agent'}</span>
<span className="block text-xs text-muted-foreground">{count} calls</span>
</span>
</button>
);
})}
</aside>
<div className="min-w-0 overflow-hidden">
<div className="grid grid-cols-[150px_minmax(0,1fr)_96px_96px] border-b border-[#ECE8E5] px-4 py-3 text-xs font-medium text-[#615854]">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleTools.map((tool) => (
<div key={tool.id} className="grid min-h-[56px] grid-cols-[150px_minmax(0,1fr)_96px_96px] items-center gap-3 border-b border-[#F0ECE9] px-4 text-sm last:border-b-0">
<span className="truncate font-medium text-[#1D1715]">{tool.toolName}</span>
<span className={`text-[#4F4642] ${containedLongTextClass}`}>
{tool.summary}
</span>
<StatusBadge status={tool.status} compact />
<span className="text-[#4F4642]">{formatToolDuration(tool, locale)}</span>
</div>
))}
</div>
</div>
)}
</Section>
);
}
function formatToolDuration(tool: TaskUiModel['tools'][number], locale: string): string {
if (typeof tool.durationMs === 'number') {
return formatTaskRuntimeDuration(tool.durationMs, locale);
}
if (tool.status === 'running' && tool.createdAt) {
const startMs = new Date(tool.createdAt).getTime();
if (!Number.isNaN(startMs)) {
return formatTaskRuntimeDuration(Date.now() - startMs, locale);
}
}
return '-';
}
function iconForArtifact(artifact: TaskUiArtifact) {
if (artifact.type === 'json') return FileJson;
if (artifact.type === 'image') return FileImage;
return FileText;
}
function WorkspaceFiles({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
return (
<Section
title="Workspace 文件"
action={
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-lg" aria-label={pickAppText(locale, '刷新', 'Refresh')}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" className="h-9 rounded-lg border-[#E6E1DE] bg-white text-xs">
Workspace
</Button>
</div>
}
>
<div className="border-t border-[#ECE8E5]">
<div className="flex h-12 items-end gap-8 px-5 text-sm">
<button className="h-10 border-b-2 border-[#1D1715] font-medium text-[#1D1715]"></button>
<button className="h-10 text-[#615854]"></button>
<button className="h-10 text-[#615854]"></button>
</div>
{model.artifacts.length === 0 ? (
<div className="p-5">
<EmptyState>{pickAppText(locale, '暂无 Workspace 文件', 'No workspace files yet')}</EmptyState>
</div>
) : (
<>
<div className="grid grid-cols-[minmax(0,1.4fr)_70px_78px_82px_96px] border-y border-[#ECE8E5] px-5 py-3 text-xs font-medium text-[#615854]">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="px-5 py-2">
{model.artifacts.slice(0, 6).map((artifact) => {
const Icon = iconForArtifact(artifact);
return (
<div key={artifact.id} className="grid min-h-[48px] grid-cols-[minmax(0,1.4fr)_70px_78px_82px_96px] items-center text-sm">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-7 w-7 items-center justify-center rounded border border-[#E1DCD8] bg-[#F8F6F4]">
<Icon className="h-4 w-4 text-[#3F7D54]" />
</span>
<span className={`font-medium ${containedLongTextClass}`}>{artifact.title}</span>
</div>
<span className="text-[#4F4642]">{artifact.type.toUpperCase()}</span>
<span className="text-[#4F4642]">{artifact.sizeLabel || '-'}</span>
<StatusBadge status={artifact.status} compact />
<div className="flex gap-2">
<Button variant="outline" size="sm" className="h-8 rounded-lg border-[#E6E1DE] bg-white px-2 text-xs" disabled={!artifact.url && !artifact.fileId}>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg border-[#E6E1DE] bg-white" disabled={!artifact.url && !artifact.fileId}>
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
{model.artifacts.length > 6 ? (
<button className="mt-1 h-9 text-sm font-medium text-[#1F6FEB]">
{pickAppText(locale, `查看全部 (${model.artifacts.length})`, `View all (${model.artifacts.length})`)}
</button>
) : null}
</div>
</>
)}
</div>
</Section>
);
}
function ResultPanel({
model,
resultAcceptance,
reviewTargetId,
}: {
model: TaskUiModel;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
}) {
const { locale } = useAppI18n();
const [busyAction, setBusyAction] = React.useState<TaskFeedbackType | null>(null);
const submit = async (type: TaskFeedbackType) => {
if (!resultAcceptance || busyAction) return;
setBusyAction(type);
try {
await resultAcceptance.onSubmit(type);
} finally {
setBusyAction(null);
}
};
return (
<Section title="本轮结果(摘要)" action={<StatusBadge status={model.result.status} compact />}>
<div id={reviewTargetId} className="space-y-4 border-t border-[#ECE8E5] p-4 scroll-mt-44">
{model.result.summary ? (
<div className="rounded-lg border border-[#E1DCD8] bg-[#FBFAF9] p-4">
<p className="max-h-[240px] overflow-auto pr-2 text-sm leading-6 text-[#1D1715]">{model.result.summary}</p>
{model.result.bullets.length > 0 ? (
<div className="mt-5 space-y-4 text-sm text-[#1D1715]">
{model.result.bullets.map((item, index) => {
const Icon = [Globe2, Table2, BarChart3, ShieldCheck][index % 4];
return (
<div key={`${item}:${index}`} className="flex gap-3">
<Icon className="h-5 w-5 shrink-0" />
<span>{item}</span>
</div>
);
})}
</div>
) : null}
</div>
) : (
<EmptyState>{pickAppText(locale, '暂无本轮结果', 'No result for this run yet')}</EmptyState>
)}
<div className="flex flex-wrap gap-3">
<Button className="h-11 rounded-lg px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('accept')}>
<CheckCircle2 className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" className="h-11 rounded-lg border-[#E6E1DE] bg-white px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('revise')}>
<ListFilter className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" className="h-11 rounded-lg border-[#E6E1DE] bg-white px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('abandon')}>
<Database className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</Section>
);
}
export function TaskExecutionWorkspace({ task, process, cards, resultAcceptance, reviewTargetId }: Props) {
const { locale } = useAppI18n();
const model = React.useMemo(
() => buildTaskUiModel({ task, process, cards, locale }),
[cards, locale, process, task],
);
const latestAttemptId = model.attempts.at(-1)?.id ?? null;
const [selectedAttemptState, setSelectedAttemptState] = React.useState<string | null>(latestAttemptId);
const selectedAttemptId = model.attempts.some((attempt) => attempt.id === selectedAttemptState)
? selectedAttemptState
: latestAttemptId;
return (
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_514px] gap-5">
<div className="min-w-0 space-y-3">
<ExecutionFlow model={model} />
<AgentDAG model={model} />
<RunPath model={model} selectedAttemptId={selectedAttemptId} onSelectAttempt={setSelectedAttemptState} />
<ToolCalls model={model} selectedAttemptId={selectedAttemptId} />
</div>
<div className="min-w-0 space-y-3">
<WorkspaceFiles model={model} />
<ResultPanel model={model} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
</div>
</div>
);
}

View File

@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status)); const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
return ( return (
<header className="sticky top-[65px] z-20 min-w-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"> <header className="sticky top-14 z-20 min-w-0 border-b border-[#E6E1DE] bg-[#FBFAF9]/95 backdrop-blur supports-[backdrop-filter]:bg-[#FBFAF9]/85">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-[1720px] flex-col gap-1.5 px-4 py-3.5 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm" className="h-11"> <Button asChild variant="outline" size="sm" className="h-11 rounded-xl border-[#E6E1DE] bg-white px-4 shadow-sm">
<Link href="/tasks"> <Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务', 'Back to tasks')} {pickAppText(locale, '返回任务', 'Back to tasks')}
</Link> </Link>
</Button> </Button>
<Button asChild variant="ghost" size="sm" className="h-11"> <Button asChild variant="outline" size="sm" className="h-11 rounded-xl border-[#E6E1DE] bg-white px-4 shadow-sm">
<Link href="/"> <Link href="/">
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '对话', 'Chat')} {pickAppText(locale, '对话', 'Chat')}
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
)} )}
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null} {activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
{showReviewLink ? ( {showReviewLink ? (
<Button asChild variant="default" size="sm" className="h-11"> <Button asChild variant="default" size="sm" className="h-11 rounded-xl px-5 shadow-[0_12px_24px_rgba(31,24,20,0.18)]">
<a href={`#${reviewTargetId}`}> <a href={`#${reviewTargetId}`}>
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '验收', 'Review')} {pickAppText(locale, '验收', 'Review')}
@ -82,12 +82,12 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1> <h1 className="truncate text-[24px] font-semibold leading-[30px]">{title}</h1>
{task.description && task.description !== title ? ( {task.description && task.description !== title ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p> <p className="mt-0.5 line-clamp-2 max-w-6xl text-sm leading-[22px] text-muted-foreground">{task.description}</p>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex shrink-0 flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
<span> <span>
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)} {pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
</span> </span>

View File

@ -1,4 +1,5 @@
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard'; export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
export { TaskExecutionWorkspace } from './TaskExecutionWorkspace';
export { TaskLiveHeader } from './TaskLiveHeader'; export { TaskLiveHeader } from './TaskLiveHeader';
export { TaskSideRail } from './TaskSideRail'; export { TaskSideRail } from './TaskSideRail';
export { TaskTimeline } from './TaskTimeline'; export { TaskTimeline } from './TaskTimeline';

View File

@ -131,7 +131,7 @@ function titleForCard(type: TaskTimelineCardType, actorName?: string, locale: Ap
case 'next_step': case 'next_step':
return pickAppText(locale, '下一步', 'Next step'); return pickAppText(locale, '下一步', 'Next step');
case 'agent_team': case 'agent_team':
return pickAppText(locale, '启动 Agent Team', 'Agent team started'); return pickAppText(locale, 'Agent Team 执行', 'Agent Team execution');
case 'agent_progress': case 'agent_progress':
return actorName || pickAppText(locale, 'Agent 进展', 'Agent progress'); return actorName || pickAppText(locale, 'Agent 进展', 'Agent progress');
case 'agent_handoff': case 'agent_handoff':

View File

@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest';
import { buildTaskUiModel } from '@/lib/task-ui-model';
import type { BackendTask, ProcessEvent, ProcessRun, SessionProcessProjection, TaskTimelineCard } from '@/types';
function task(overrides: Partial<BackendTask> = {}): BackendTask {
return {
task_id: 'task-1',
session_id: 'web:default',
description: 'Build MGM report',
goal: 'Build MGM report',
constraints: [],
priority: 0,
status: 'awaiting_acceptance',
creator: 'user',
created_at: '2026-06-24T00:00:00.000Z',
updated_at: '2026-06-24T00:01:00.000Z',
run_ids: ['main-run'],
skill_names: ['mgm-galaxy-financial-chart-report-safe'],
feedback: [],
metadata: {},
...overrides,
};
}
function process(overrides: Partial<SessionProcessProjection> = {}): SessionProcessProjection {
return {
runs: [],
events: [],
artifacts: [],
...overrides,
};
}
describe('buildTaskUiModel', () => {
it('keeps single-agent tasks out of the Agent Team tree and summarizes tool results', () => {
const cards: TaskTimelineCard[] = [
{
id: 'tool-start',
taskId: 'task-1',
runId: 'main-run',
type: 'tool_call',
title: 'Calling tool: web_search',
actorName: 'web_search',
status: 'running',
createdAt: '2026-06-24T00:00:10.000Z',
details: {
tool_name: 'web_search',
tool_call_id: 'call-1',
arguments: '{"query":"MGM China annual results"}',
},
},
{
id: 'tool-result',
taskId: 'task-1',
runId: 'main-run',
type: 'tool_result',
title: 'Tool result: web_search',
actorName: 'web_search',
status: 'done',
createdAt: '2026-06-24T00:00:20.000Z',
summary: '{"success":true,"query":"MGM China annual results","quality":"low","results":[{"title":"bad"}]}',
details: {
tool_name: 'web_search',
tool_call_id: 'call-1',
result_summary: '{"success":true,"query":"MGM China annual results","quality":"low","results":[{"title":"bad"}]}',
},
},
];
const model = buildTaskUiModel({
task: task(),
process: process({
runs: [
{
run_id: 'main-run',
parent_run_id: 'task:task-1:attempt:1',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: 'Main Agent',
title: 'Final synthesis',
source: 'task_synthesis',
status: 'done',
started_at: '2026-06-24T00:00:00.000Z',
},
],
}),
cards,
locale: 'zh-Hans',
});
expect(model.executionMode).toBe('single');
expect(model.team.hasTeam).toBe(false);
expect(model.agentTree).toEqual([]);
expect(model.tools).toHaveLength(1);
expect(model.tools[0].status).toBe('done');
expect(model.tools[0].quality).toBe('low');
expect(model.tools[0].summary).toContain('MGM China annual results');
});
it('models real task team nodes separately from the main agent', () => {
const cards: TaskTimelineCard[] = [
{
id: 'team',
taskId: 'task-1',
runId: 'task:task-1:attempt:1',
type: 'agent_team',
title: 'Agent Team',
actorName: 'Task Team',
status: 'error',
createdAt: '2026-06-24T00:00:10.000Z',
details: {
task_outcome: 'incomplete',
node_ids: ['collect', 'report'],
incomplete_node_ids: ['collect'],
},
},
];
const runs: ProcessRun[] = [
{
run_id: 'main-run',
parent_run_id: 'task:task-1:attempt:1',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: 'Main Agent',
title: 'Final synthesis',
source: 'task_synthesis',
status: 'done',
started_at: '2026-06-24T00:00:00.000Z',
},
{
run_id: 'collect-run',
parent_run_id: 'task:task-1:attempt:1',
session_id: 'web:default:team:collect',
actor_type: 'agent',
actor_id: 'collect',
actor_name: 'collect',
title: 'collect',
source: 'task_team',
status: 'error',
started_at: '2026-06-24T00:00:10.000Z',
metadata: { node_id: 'collect' },
},
];
const model = buildTaskUiModel({
task: task(),
process: process({ runs, events: [] as ProcessEvent[] }),
cards,
locale: 'zh-Hans',
});
expect(model.executionMode).toBe('team');
expect(model.team.hasTeam).toBe(true);
expect(model.team.outcome).toBe('incomplete');
expect(model.agentTree.map((node) => node.name)).toEqual(['collect']);
});
it('groups tools and result versions by task attempt', () => {
const cards: TaskTimelineCard[] = [
{
id: 'run-1-tool-call',
taskId: 'task-1',
runId: 'main-run-1',
type: 'tool_call',
title: 'Calling tool: web_fetch',
actorName: 'web_fetch',
status: 'running',
createdAt: '2026-06-24T00:00:10.000Z',
details: { tool_name: 'web_fetch', tool_call_id: 'call-1' },
},
{
id: 'run-1-tool-result',
taskId: 'task-1',
runId: 'main-run-1',
type: 'tool_result',
title: 'Tool result: web_fetch',
actorName: 'web_fetch',
status: 'error',
createdAt: '2026-06-24T00:00:15.000Z',
details: { tool_name: 'web_fetch', tool_call_id: 'call-1' },
},
{
id: 'run-1-result',
taskId: 'task-1',
runId: 'main-run-1',
type: 'result',
title: 'Result ready',
actorName: 'Main Agent',
status: 'error',
createdAt: '2026-06-24T00:00:20.000Z',
summary: 'First result',
},
{
id: 'run-2-tool-call',
taskId: 'task-1',
runId: 'main-run-2',
type: 'tool_call',
title: 'Calling tool: web_search',
actorName: 'web_search',
status: 'running',
createdAt: '2026-06-24T00:02:10.000Z',
details: { tool_name: 'web_search', tool_call_id: 'call-2' },
},
{
id: 'run-2-tool-result',
taskId: 'task-1',
runId: 'main-run-2',
type: 'tool_result',
title: 'Tool result: web_search',
actorName: 'web_search',
status: 'done',
createdAt: '2026-06-24T00:02:30.000Z',
details: { tool_name: 'web_search', tool_call_id: 'call-2' },
},
{
id: 'run-2-result',
taskId: 'task-1',
runId: 'main-run-2',
type: 'result',
title: 'Result ready',
actorName: 'Main Agent',
status: 'done',
createdAt: '2026-06-24T00:03:00.000Z',
summary: 'Second result',
},
];
const runs: ProcessRun[] = [
{
run_id: 'task:task-1:attempt:1',
session_id: 'web:default',
actor_type: 'system',
actor_id: 'task',
actor_name: 'Task Planner',
title: 'single plan',
source: 'task_mode',
status: 'error',
started_at: '2026-06-24T00:00:00.000Z',
metadata: { attempt_index: 1 },
},
{
run_id: 'main-run-1',
parent_run_id: 'task:task-1:attempt:1',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: 'Main Agent',
title: 'Final synthesis',
source: 'task_synthesis',
status: 'error',
started_at: '2026-06-24T00:00:05.000Z',
finished_at: '2026-06-24T00:00:20.000Z',
metadata: { attempt_index: 1 },
},
{
run_id: 'task:task-1:attempt:2',
session_id: 'web:default',
actor_type: 'system',
actor_id: 'task',
actor_name: 'Task Planner',
title: 'single plan',
source: 'task_mode',
status: 'done',
started_at: '2026-06-24T00:02:00.000Z',
finished_at: '2026-06-24T00:03:00.000Z',
metadata: { attempt_index: 2 },
},
{
run_id: 'main-run-2',
parent_run_id: 'task:task-1:attempt:2',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main-agent',
actor_name: 'Main Agent',
title: 'Final synthesis',
source: 'task_synthesis',
status: 'done',
started_at: '2026-06-24T00:02:05.000Z',
finished_at: '2026-06-24T00:03:00.000Z',
metadata: { attempt_index: 2 },
},
];
const model = buildTaskUiModel({
task: task({ run_ids: ['main-run-1', 'main-run-2'] }),
process: process({ runs }),
cards,
locale: 'zh-Hans',
});
expect(model.tools).toHaveLength(2);
expect(model.attempts).toHaveLength(2);
expect(model.attempts[0].index).toBe(1);
expect(model.attempts[0].tools.map((tool) => tool.toolName)).toEqual(['web_fetch']);
expect(model.attempts[0].result?.summary).toBe('First result');
expect(model.attempts[1].index).toBe(2);
expect(model.attempts[1].tools.map((tool) => tool.toolName)).toEqual(['web_search']);
expect(model.attempts[1].result?.summary).toBe('Second result');
});
});

View File

@ -0,0 +1,692 @@
import { pickAppText, type AppLocale } from '@/lib/i18n/core';
import type { BackendTask, ProcessArtifact, ProcessRun, SessionProcessProjection, TaskTimelineCard } from '@/types';
export type TaskUiStatus = 'done' | 'running' | 'waiting' | 'error' | 'cancelled';
export type TaskUiStep = {
id: string;
title: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
kind: 'task' | 'skill' | 'tool' | 'agent' | 'artifact' | 'result';
};
export type TaskUiSkill = {
id: string;
name: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
};
export type TaskUiToolCall = {
id: string;
runId?: string | null;
toolCallId?: string | null;
toolName: string;
actorName: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
finishedAt?: string;
durationMs?: number | null;
query?: string;
url?: string;
quality?: string;
resultCount?: number;
};
export type TaskUiAttemptRun = {
runId: string;
title: string;
actorName: string;
source?: string | null;
status: TaskUiStatus;
startedAt: string;
finishedAt?: string | null;
};
export type TaskUiAttempt = {
id: string;
index: number;
title: string;
status: TaskUiStatus;
startedAt: string;
finishedAt?: string | null;
runs: TaskUiAttemptRun[];
tools: TaskUiToolCall[];
result?: {
title: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
};
};
export type TaskUiAgentNode = {
runId: string;
parentRunId: string | null;
name: string;
title: string;
summary: string;
status: TaskUiStatus;
progress: number;
children: TaskUiAgentNode[];
};
export type TaskUiArtifact = {
id: string;
runId: string;
actorName?: string;
title: string;
type: ProcessArtifact['artifact_type'];
summary: string;
createdAt: string;
fileId?: string;
url?: string;
status: TaskUiStatus;
sizeLabel?: string;
};
export type TaskUiModel = {
executionMode: 'single' | 'team';
team: {
hasTeam: boolean;
status: TaskUiStatus;
outcome: string;
nodeIds: string[];
incompleteNodeIds: string[];
summary: string;
};
summary: TaskUiStep;
skills: TaskUiSkill[];
tools: TaskUiToolCall[];
attempts: TaskUiAttempt[];
agentTree: TaskUiAgentNode[];
artifacts: TaskUiArtifact[];
steps: TaskUiStep[];
result: {
status: TaskUiStatus;
title: string;
summary: string;
bullets: string[];
};
};
const WAITING_TASK_STATUSES = new Set(['open', 'queued', 'awaiting_acceptance', 'needs_revision']);
const RUNNING_TASK_STATUSES = new Set(['running']);
const DONE_TASK_STATUSES = new Set(['closed', 'done', 'completed', 'satisfied']);
const ERROR_TASK_STATUSES = new Set(['error', 'failed']);
const CANCELLED_TASK_STATUSES = new Set(['cancelled', 'abandoned']);
function normalizeStatus(status?: string | null): TaskUiStatus {
const value = String(status || '').toLowerCase();
if (DONE_TASK_STATUSES.has(value) || value === 'done') return 'done';
if (RUNNING_TASK_STATUSES.has(value)) return 'running';
if (ERROR_TASK_STATUSES.has(value)) return 'error';
if (CANCELLED_TASK_STATUSES.has(value)) return 'cancelled';
if (WAITING_TASK_STATUSES.has(value) || value === 'waiting' || value === 'queued' || !value) return 'waiting';
return 'running';
}
export function taskUiStatusLabel(status: TaskUiStatus, locale: AppLocale | string): string {
const labels: Record<TaskUiStatus, [string, string]> = {
done: ['已完成', 'Done'],
running: ['进行中', 'Running'],
waiting: ['等待中', 'Waiting'],
error: ['失败', 'Failed'],
cancelled: ['已取消', 'Cancelled'],
};
const label = labels[status];
return pickAppText(locale, label[0], label[1]);
}
export function taskUiStatusClass(status: TaskUiStatus): string {
if (status === 'done') return 'border-[#D6E2D5] bg-[#F4F8F3] text-[#557052]';
if (status === 'running') return 'border-[#E8D7B2] bg-[#FFF8EA] text-[#9B6B12]';
if (status === 'error') return 'border-[#E8C8C2] bg-[#FFF4F2] text-[#9D3D2F]';
if (status === 'cancelled') return 'border-[#DED9D5] bg-[#F4F2F0] text-[#756A64]';
return 'border-[#DDD8D4] bg-[#F8F6F4] text-[#746C67]';
}
function titleForTask(task: BackendTask): string {
return task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
}
function summarizeTask(task: BackendTask): string {
return task.description || task.goal || String(task.metadata?.summary || '') || task.task_id;
}
function statusRank(status: TaskUiStatus): number {
if (status === 'error') return 5;
if (status === 'running') return 4;
if (status === 'waiting') return 3;
if (status === 'cancelled') return 2;
return 1;
}
function mergeStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus {
return statusRank(next) > statusRank(current) ? next : current;
}
function mergeToolStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus {
if (next === 'error' || current === 'error') return 'error';
if (next === 'done' || current === 'done') return 'done';
if (next === 'running' || current === 'running') return 'running';
if (next === 'cancelled' || current === 'cancelled') return 'cancelled';
return 'waiting';
}
function firstString(...values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function countArray(value: unknown): number | undefined {
return Array.isArray(value) ? value.length : undefined;
}
function summarizeToolPayload(toolName: string, card: TaskTimelineCard): {
summary: string;
query?: string;
url?: string;
quality?: string;
resultCount?: number;
} {
const details = card.details ?? {};
const args = parseJsonObject(firstString(details.arguments));
const result = parseJsonObject(firstString(details.result_summary, card.summary));
const source = result ?? args ?? {};
const query = firstString(source.query, args?.query);
const url = firstString(source.url, args?.url);
const quality = firstString(source.quality);
const resultCount = countArray(source.results) ?? countArray(source.links);
if (toolName === 'web_search') {
return {
query,
quality,
resultCount,
summary: [
query ? `Query: ${query}` : 'Search query',
typeof resultCount === 'number' ? `${resultCount} result(s)` : '',
].filter(Boolean).join(' · '),
};
}
if (toolName === 'web_fetch') {
return {
url,
resultCount,
summary: [
firstString(source.title) || (url ? `Fetch ${url}` : 'Fetch web page'),
firstString(source.status_code) ? `HTTP ${String(source.status_code)}` : '',
typeof resultCount === 'number' ? `${resultCount} link(s)` : '',
].filter(Boolean).join(' · '),
};
}
return {
query,
url,
quality,
resultCount,
summary: firstString(card.summary, details.result_summary, card.title) || toolName,
};
}
function toolNameFromCard(card: TaskTimelineCard): string {
return firstString(
card.details?.tool_name,
card.details?.tool,
card.details?.name,
card.actorName,
card.title
);
}
function buildSkills(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiSkill[] {
const createdAt = task.created_at || task.updated_at;
const names = new Set(task.skill_names.filter(Boolean));
for (const card of cards) {
if (card.type !== 'skill') continue;
const detailNames = card.details?.skill_names;
if (Array.isArray(detailNames)) {
detailNames.forEach((item) => {
if (typeof item === 'string' && item.trim()) names.add(item.trim());
});
}
}
return Array.from(names).map((name, index) => {
const card = cards.find((item) => item.type === 'skill' && (item.title === name || item.summary?.includes(name)));
return {
id: `${name}:${index}`,
name,
summary: card?.summary || '',
status: normalizeStatus(card?.status || (task.status === 'running' || task.status === 'open' ? 'running' : 'done')),
createdAt: card?.createdAt || createdAt,
};
});
}
function toolAggregateStatus(toolCards: TaskTimelineCard[]): TaskUiStatus {
const calls = new Map<string, { started: boolean; finished: boolean; error: boolean }>();
for (const card of toolCards) {
const toolName = toolNameFromCard(card) || card.title;
const toolCallId = firstString(card.details?.tool_call_id);
const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : `${card.runId || '-'}:${toolName}:${card.id}`;
const item = calls.get(key) ?? { started: false, finished: false, error: false };
if (card.type === 'tool_call') item.started = true;
if (card.type === 'tool_result') item.finished = true;
if (normalizeStatus(card.status) === 'error') item.error = true;
calls.set(key, item);
}
const values = Array.from(calls.values());
if (values.some((item) => item.error)) return 'error';
if (values.length > 0 && values.every((item) => item.finished)) return 'done';
if (values.some((item) => item.started)) return 'running';
return 'waiting';
}
function toolActorNameForRun(run: ProcessRun | undefined): string {
if (!run) return 'Agent';
if (run.source === 'task_team' || run.metadata?.node_id) {
return run.actor_name || run.title || String(run.metadata?.node_id || 'Agent');
}
return 'Agent';
}
function buildTools(cards: TaskTimelineCard[], runs: ProcessRun[]): TaskUiToolCall[] {
const map = new Map<string, TaskUiToolCall>();
const runsById = new Map(runs.map((run) => [run.run_id, run]));
for (const card of cards) {
if (card.type !== 'tool_call' && card.type !== 'tool_result') continue;
const toolName = toolNameFromCard(card) || card.title;
const toolCallId = firstString(card.details?.tool_call_id);
const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : card.id;
const status = normalizeStatus(card.status || (card.type === 'tool_result' ? 'done' : 'running'));
const summary = summarizeToolPayload(toolName, card);
const existing = map.get(key);
if (existing) {
const finishedAt = card.type === 'tool_result' ? card.createdAt : existing.finishedAt;
map.set(key, {
...existing,
summary: summary.summary || existing.summary,
status: mergeToolStatus(existing.status, status),
finishedAt,
durationMs: calculateDurationMs(existing.createdAt, finishedAt),
query: summary.query || existing.query,
url: summary.url || existing.url,
quality: summary.quality || existing.quality,
resultCount: summary.resultCount ?? existing.resultCount,
});
continue;
}
map.set(key, {
id: key,
runId: card.runId,
toolCallId,
toolName,
actorName: toolActorNameForRun(card.runId ? runsById.get(card.runId) : undefined),
summary: summary.summary || card.title,
status,
createdAt: card.createdAt,
finishedAt: card.type === 'tool_result' ? card.createdAt : undefined,
durationMs: calculateDurationMs(card.createdAt, card.type === 'tool_result' ? card.createdAt : undefined),
query: summary.query,
url: summary.url,
quality: summary.quality,
resultCount: summary.resultCount,
});
}
return Array.from(map.values()).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
function calculateDurationMs(start?: string | null, end?: string | null): number | null {
if (!start || !end) return null;
const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime();
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return null;
return endMs - startMs;
}
function attemptIndexForRun(run: ProcessRun, fallback: number): number {
const metadataIndex = run.metadata?.attempt_index;
if (typeof metadataIndex === 'number' && Number.isFinite(metadataIndex)) return metadataIndex;
if (typeof metadataIndex === 'string' && /^\d+$/.test(metadataIndex)) return Number(metadataIndex);
const match = run.run_id.match(/:attempt:(\d+)/);
return match ? Number(match[1]) : fallback;
}
function runStartedAt(run: ProcessRun): string {
return run.started_at || run.finished_at || '';
}
function runFinishedAt(run: ProcessRun): string | null | undefined {
return run.finished_at;
}
function maxTime(values: Array<string | null | undefined>): string | null {
let selected: string | null = null;
let selectedMs = -Infinity;
for (const value of values) {
if (!value) continue;
const time = new Date(value).getTime();
if (!Number.isNaN(time) && time > selectedMs) {
selected = value;
selectedMs = time;
}
}
return selected;
}
function attemptStatus(runs: ProcessRun[], planner?: ProcessRun): TaskUiStatus {
const statuses = [...runs.map((run) => normalizeStatus(run.status)), normalizeStatus(planner?.status)];
if (statuses.some((status) => status === 'running')) return 'running';
if (statuses.some((status) => status === 'error')) return 'error';
if (statuses.some((status) => status === 'cancelled')) return 'cancelled';
if (statuses.some((status) => status === 'done')) return 'done';
return 'waiting';
}
function collectChildRunIds(rootRunId: string, runs: ProcessRun[]): Set<string> {
const ids = new Set([rootRunId]);
let changed = true;
while (changed) {
changed = false;
for (const run of runs) {
if (run.parent_run_id && ids.has(run.parent_run_id) && !ids.has(run.run_id)) {
ids.add(run.run_id);
changed = true;
}
}
}
return ids;
}
function buildAttempts(
process: SessionProcessProjection,
cards: TaskTimelineCard[],
tools: TaskUiToolCall[],
locale: AppLocale | string,
): TaskUiAttempt[] {
const runs = process.runs ?? [];
const plannerRuns = runs
.filter((run) => run.source === 'task_mode' || /:attempt:\d+/.test(run.run_id))
.sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime());
const attemptRuns = plannerRuns.length > 0 ? plannerRuns : [];
const attempts = attemptRuns.map((planner, index) => {
const runIds = collectChildRunIds(planner.run_id, runs);
const groupedRuns = runs
.filter((run) => runIds.has(run.run_id))
.sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime());
const groupedTools = tools.filter((tool) => tool.runId && runIds.has(tool.runId));
const resultCard = [...cards].reverse().find((card) => card.type === 'result' && card.runId && runIds.has(card.runId));
const attemptIndex = attemptIndexForRun(planner, index + 1);
return {
id: planner.run_id,
index: attemptIndex,
title: pickAppText(locale, `${attemptIndex} 次执行`, `Attempt ${attemptIndex}`),
status: attemptStatus(groupedRuns, planner),
startedAt: runStartedAt(planner),
finishedAt: maxTime(groupedRuns.map(runFinishedAt)) ?? planner.finished_at,
runs: groupedRuns.map((run) => ({
runId: run.run_id,
title: run.title || run.actor_name || run.actor_id,
actorName: run.actor_name || run.actor_id,
source: run.source,
status: normalizeStatus(run.status),
startedAt: runStartedAt(run),
finishedAt: run.finished_at,
})),
tools: groupedTools,
result: resultCard
? {
title: resultCard.title,
summary: resultCard.summary || '',
status: normalizeStatus(resultCard.status),
createdAt: resultCard.createdAt,
}
: undefined,
};
});
if (attempts.length > 0) return attempts;
return [
{
id: 'single-run',
index: 1,
title: pickAppText(locale, '本次执行', 'Current run'),
status: tools.some((tool) => tool.status === 'running') ? 'running' : tools.length > 0 ? 'done' : 'waiting',
startedAt: cards[0]?.createdAt || '',
runs: runs.map((run) => ({
runId: run.run_id,
title: run.title || run.actor_name || run.actor_id,
actorName: run.actor_name || run.actor_id,
source: run.source,
status: normalizeStatus(run.status),
startedAt: runStartedAt(run),
finishedAt: run.finished_at,
})),
tools,
result: undefined,
},
];
}
function progressForStatus(status: TaskUiStatus): number {
if (status === 'done') return 100;
if (status === 'running') return 58;
if (status === 'error' || status === 'cancelled') return 100;
return 12;
}
function buildAgentTree(runs: ProcessRun[]): TaskUiAgentNode[] {
const nodes = new Map<string, TaskUiAgentNode>();
const teamRuns = runs.filter((run) => run.source === 'task_team' || Boolean(run.metadata?.node_id));
for (const run of teamRuns) {
const status = normalizeStatus(run.status);
nodes.set(run.run_id, {
runId: run.run_id,
parentRunId: run.parent_run_id ?? null,
name: run.actor_name || run.actor_id || run.title,
title: run.title || run.actor_name || run.actor_id,
summary: run.summary || String(run.metadata?.summary || ''),
status,
progress: progressForStatus(status),
children: [],
});
}
const roots: TaskUiAgentNode[] = [];
for (const node of Array.from(nodes.values())) {
if (node.parentRunId && nodes.has(node.parentRunId)) {
nodes.get(node.parentRunId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function buildTeam(cards: TaskTimelineCard[]): TaskUiModel['team'] {
const teamCard = [...cards].reverse().find((card) => card.type === 'agent_team');
if (!teamCard) {
return {
hasTeam: false,
status: 'waiting',
outcome: 'single',
nodeIds: [],
incompleteNodeIds: [],
summary: '',
};
}
const nodeIds = stringList(teamCard.details?.node_ids);
const incompleteNodeIds = stringList(teamCard.details?.incomplete_node_ids);
const outcome = firstString(teamCard.details?.task_outcome) || (teamCard.status === 'error' ? 'incomplete' : 'complete');
return {
hasTeam: true,
status: normalizeStatus(teamCard.status),
outcome,
nodeIds,
incompleteNodeIds,
summary: teamCard.summary || (outcome === 'complete' ? 'Agent Team completed' : 'Team 执行未完成 / 子节点失败'),
};
}
function buildArtifacts(process: SessionProcessProjection): TaskUiArtifact[] {
return process.artifacts.map((artifact) => ({
id: artifact.artifact_id,
runId: artifact.run_id,
actorName: artifact.actor_name,
title: artifact.title || artifact.artifact_id,
type: artifact.artifact_type,
summary: firstString(artifact.metadata?.summary, artifact.content, artifact.url) || artifact.artifact_type,
createdAt: artifact.created_at,
fileId: artifact.file_id,
url: artifact.url,
status: normalizeStatus(firstString(artifact.metadata?.status, artifact.metadata?.state) || 'done'),
sizeLabel: firstString(artifact.metadata?.size_label, artifact.metadata?.size, artifact.metadata?.file_size),
}));
}
function stepKind(card: TaskTimelineCard): TaskUiStep['kind'] {
if (card.type === 'skill') return 'skill';
if (card.type === 'tool_call' || card.type === 'tool_result') return 'tool';
if (card.type === 'agent_team' || card.type === 'agent_progress' || card.type === 'agent_handoff') return 'agent';
if (card.type === 'artifact') return 'artifact';
if (card.type === 'result' || card.type === 'acceptance') return 'result';
return 'task';
}
function buildSteps(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiStep[] {
const taskStep: TaskUiStep = {
id: `summary:${task.task_id}`,
title: titleForTask(task),
summary: summarizeTask(task),
status: normalizeStatus(task.status),
createdAt: task.created_at,
kind: 'task',
};
const skillCard = cards.find((card) => card.type === 'skill');
const teamCard = cards.find((card) => card.type === 'agent_team');
const resultCard = [...cards].reverse().find((card) => card.type === 'result');
const toolCards = cards.filter((card) => card.type === 'tool_call' || card.type === 'tool_result');
const toolNames = new Set(toolCards.map(toolNameFromCard).filter(Boolean));
const cardSteps: Array<TaskUiStep | null> = [
skillCard
? {
id: `${skillCard.id}:step`,
title: pickAppText(locale, '选择 Skill', 'Skill selected'),
summary: skillCard.summary || '',
status: normalizeStatus(skillCard.status),
createdAt: skillCard.createdAt,
kind: 'skill' as const,
}
: null,
toolCards.length
? {
id: `tools:${task.task_id}`,
title: pickAppText(locale, '调用工具', 'Tool calls'),
summary: `${toolCards.filter((card) => card.type === 'tool_result').length || toolCards.length} calls · ${Array.from(toolNames).slice(0, 3).join(', ')}`,
status: toolAggregateStatus(toolCards),
createdAt: toolCards[0].createdAt,
kind: 'tool' as const,
}
: null,
teamCard
? {
id: `${teamCard.id}:step`,
title: pickAppText(locale, 'Agent Team 执行', 'Agent Team execution'),
summary: teamCard.summary || '',
status: normalizeStatus(teamCard.status),
createdAt: teamCard.createdAt,
kind: 'agent' as const,
}
: null,
resultCard
? {
id: `${resultCard.id}:step`,
title: pickAppText(locale, '生成结果', 'Result ready'),
summary: resultCard.summary || '',
status: normalizeStatus(resultCard.status),
createdAt: resultCard.createdAt,
kind: 'result' as const,
}
: null,
];
return [taskStep, ...cardSteps.filter((step): step is TaskUiStep => step !== null)];
}
function buildResult(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiModel['result'] {
const resultCard = [...cards].reverse().find((card) => card.type === 'result');
const summary = resultCard?.summary || firstString(task.metadata?.result_summary, task.close_reason);
const bullets = summary
.replace(/[。.!?]\s+/g, '\n')
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 3);
return {
status: normalizeStatus(resultCard?.status || task.status),
title: resultCard?.title || pickAppText(locale, '本轮结果', 'Current result'),
summary: summary || '',
bullets,
};
}
export function buildTaskUiModel({
task,
process,
cards,
locale,
}: {
task: BackendTask;
process: SessionProcessProjection;
cards: TaskTimelineCard[];
locale: AppLocale | string;
}): TaskUiModel {
const steps = buildSteps(task, cards, locale);
const team = buildTeam(cards);
const tools = buildTools(cards, process.runs);
return {
executionMode: team.hasTeam ? 'team' : 'single',
team,
summary: steps[0],
skills: buildSkills(task, cards, locale),
tools,
attempts: buildAttempts(process, cards, tools, locale),
agentTree: buildAgentTree(process.runs),
artifacts: buildArtifacts(process),
steps,
result: buildResult(task, cards, locale),
};
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

View File

@ -0,0 +1,138 @@
/* html-ppt :: animations.css
* Apply by adding class="anim-<name>" or data-anim="<name>".
* Durations are deliberately snappy; tweak --anim-dur per element.
*/
:root{--anim-dur:.7s;--anim-ease:cubic-bezier(.4,0,.2,1)}
/* ---------- FADE DIRECTIONALS ---------- */
@keyframes kf-fade-up{from{opacity:0;transform:translateY(32px)}to{opacity:1;transform:none}}
@keyframes kf-fade-down{from{opacity:0;transform:translateY(-32px)}to{opacity:1;transform:none}}
@keyframes kf-fade-left{from{opacity:0;transform:translateX(-40px)}to{opacity:1;transform:none}}
@keyframes kf-fade-right{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
.anim-fade-up{animation:kf-fade-up var(--anim-dur) var(--anim-ease) both}
.anim-fade-down{animation:kf-fade-down var(--anim-dur) var(--anim-ease) both}
.anim-fade-left{animation:kf-fade-left var(--anim-dur) var(--anim-ease) both}
.anim-fade-right{animation:kf-fade-right var(--anim-dur) var(--anim-ease) both}
/* ---------- RISE / DROP / ZOOM / BLUR / GLITCH ---------- */
@keyframes kf-rise{from{opacity:0;transform:translateY(60px) scale(.97);filter:blur(6px)}to{opacity:1;transform:none;filter:none}}
@keyframes kf-drop{from{opacity:0;transform:translateY(-60px) scale(.97)}to{opacity:1;transform:none}}
@keyframes kf-zoom{0%{opacity:0;transform:scale(.6)}60%{transform:scale(1.04)}100%{opacity:1;transform:scale(1)}}
@keyframes kf-blur{from{opacity:0;filter:blur(18px)}to{opacity:1;filter:none}}
@keyframes kf-glitch{0%{opacity:0;transform:translateX(0);clip-path:inset(0 0 0 0)}
20%{opacity:1;transform:translateX(-6px);clip-path:inset(20% 0 30% 0)}
40%{transform:translateX(4px);clip-path:inset(50% 0 10% 0)}
60%{transform:translateX(-3px);clip-path:inset(10% 0 60% 0)}
80%{transform:translateX(2px);clip-path:inset(0 0 0 0)}
100%{opacity:1;transform:none}}
.anim-rise-in{animation:kf-rise .9s var(--anim-ease) both}
.anim-drop-in{animation:kf-drop .8s var(--anim-ease) both}
.anim-zoom-pop{animation:kf-zoom .7s cubic-bezier(.22,1.3,.36,1) both}
.anim-blur-in{animation:kf-blur .8s var(--anim-ease) both}
.anim-glitch-in{animation:kf-glitch .8s steps(5,end) both}
/* ---------- TYPEWRITER ---------- */
.anim-typewriter{display:inline-block;overflow:hidden;white-space:nowrap;border-right:2px solid currentColor;
width:0;animation:kf-type 2.4s steps(40,end) forwards, kf-caret 1s step-end infinite}
@keyframes kf-type{to{width:100%}}
@keyframes kf-caret{50%{border-color:transparent}}
/* ---------- GLOW / SHIMMER / GRADIENT-FLOW ---------- */
@keyframes kf-neon{0%,100%{text-shadow:0 0 8px var(--accent),0 0 20px var(--accent)}
50%{text-shadow:0 0 16px var(--accent),0 0 40px var(--accent),0 0 80px var(--accent)}}
.anim-neon-glow{animation:kf-neon 2s ease-in-out infinite}
.anim-shimmer-sweep{position:relative;overflow:hidden}
.anim-shimmer-sweep::after{content:"";position:absolute;inset:0;
background:linear-gradient(110deg,transparent 40%,rgba(255,255,255,.55) 50%,transparent 60%);
transform:translateX(-100%);animation:kf-shimmer 2.4s var(--anim-ease) infinite}
@keyframes kf-shimmer{to{transform:translateX(100%)}}
.anim-gradient-flow{background:linear-gradient(90deg,var(--accent),var(--accent-2,var(--accent)),var(--accent-3,var(--accent)),var(--accent));
background-size:300% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;
animation:kf-gradflow 4s linear infinite}
@keyframes kf-gradflow{to{background-position:300% 0}}
/* ---------- STAGGER LIST ---------- */
.anim-stagger-list > *{opacity:0;animation:kf-rise .65s var(--anim-ease) both}
.anim-stagger-list > *:nth-child(1){animation-delay:.05s}
.anim-stagger-list > *:nth-child(2){animation-delay:.15s}
.anim-stagger-list > *:nth-child(3){animation-delay:.25s}
.anim-stagger-list > *:nth-child(4){animation-delay:.35s}
.anim-stagger-list > *:nth-child(5){animation-delay:.45s}
.anim-stagger-list > *:nth-child(6){animation-delay:.55s}
.anim-stagger-list > *:nth-child(7){animation-delay:.65s}
.anim-stagger-list > *:nth-child(8){animation-delay:.75s}
.anim-stagger-list > *:nth-child(n+9){animation-delay:.85s}
/* ---------- COUNTER-UP (JS-driven, marker class only) ---------- */
.counter{font-variant-numeric:tabular-nums}
/* ---------- SVG PATH DRAW ---------- */
.anim-path-draw path,.anim-path-draw line,.anim-path-draw polyline,.anim-path-draw circle,.anim-path-draw rect{
stroke-dasharray:1000;stroke-dashoffset:1000;animation:kf-draw 2s var(--anim-ease) forwards}
@keyframes kf-draw{to{stroke-dashoffset:0}}
/* ---------- PARALLAX TILT (hover) ---------- */
.anim-parallax-tilt{transform-style:preserve-3d;transition:transform .4s var(--anim-ease)}
.anim-parallax-tilt:hover{transform:perspective(900px) rotateX(6deg) rotateY(-8deg) translateZ(10px)}
/* ---------- CARD FLIP 3D ---------- */
@keyframes kf-flip{from{transform:perspective(1200px) rotateY(-90deg);opacity:0}
to{transform:perspective(1200px) rotateY(0);opacity:1}}
.anim-card-flip-3d{animation:kf-flip .9s var(--anim-ease) both;transform-style:preserve-3d;backface-visibility:hidden}
/* ---------- CUBE ROTATE 3D ---------- */
@keyframes kf-cube{from{transform:perspective(1200px) rotateX(20deg) rotateY(-90deg) translateZ(-200px);opacity:0}
to{transform:perspective(1200px) rotateX(0) rotateY(0) translateZ(0);opacity:1}}
.anim-cube-rotate-3d{animation:kf-cube 1s var(--anim-ease) both}
/* ---------- PAGE TURN 3D ---------- */
@keyframes kf-pageturn{from{transform:perspective(1600px) rotateY(-85deg);transform-origin:left center;opacity:0}
to{transform:perspective(1600px) rotateY(0);opacity:1}}
.anim-page-turn-3d{animation:kf-pageturn 1s var(--anim-ease) both;transform-origin:left center}
/* ---------- PERSPECTIVE ZOOM ---------- */
@keyframes kf-pzoom{from{opacity:0;transform:perspective(1400px) translateZ(-400px) rotateX(12deg)}
to{opacity:1;transform:none}}
.anim-perspective-zoom{animation:kf-pzoom 1s var(--anim-ease) both}
/* ---------- MARQUEE SCROLL ---------- */
.anim-marquee-scroll{display:flex;gap:48px;white-space:nowrap;animation:kf-marquee 20s linear infinite}
@keyframes kf-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
/* ---------- KEN BURNS ---------- */
@keyframes kf-kenburns{0%{transform:scale(1) translate(0,0)}100%{transform:scale(1.15) translate(-2%,-1%)}}
.anim-kenburns{animation:kf-kenburns 14s ease-in-out infinite alternate}
/* ---------- CONFETTI BURST (pseudo — pure CSS sparkles) ---------- */
.anim-confetti-burst{position:relative}
.anim-confetti-burst::before,.anim-confetti-burst::after{
content:"";position:absolute;top:50%;left:50%;width:8px;height:8px;border-radius:50%;
background:var(--accent);box-shadow:
20px -30px 0 var(--accent-2,var(--accent)),-25px -20px 0 var(--accent-3,var(--accent)),
30px 20px 0 var(--good,#1aaf6c),-30px 25px 0 var(--warn,#f5a524),
40px -10px 0 var(--bad,#e0445a),-45px 0 0 var(--accent),
10px 40px 0 var(--accent-2,var(--accent)),-15px -40px 0 var(--accent-3,var(--accent));
opacity:0;animation:kf-confetti 1.2s var(--anim-ease) forwards}
.anim-confetti-burst::after{animation-delay:.15s;transform:rotate(45deg)}
@keyframes kf-confetti{0%{opacity:0;transform:scale(.2)}30%{opacity:1}100%{opacity:0;transform:scale(2.2)}}
/* ---------- SPOTLIGHT ---------- */
@keyframes kf-spot{0%{clip-path:circle(0% at 50% 50%)}100%{clip-path:circle(140% at 50% 50%)}}
.anim-spotlight{animation:kf-spot 1.1s var(--anim-ease) both}
/* ---------- MORPH SHAPE (SVG) ---------- */
.anim-morph-shape path{animation:kf-morph 6s ease-in-out infinite alternate}
@keyframes kf-morph{0%{d:path("M60,120 Q120,20 180,120 T300,120")}
100%{d:path("M60,120 Q120,220 180,120 T300,120")}}
/* ---------- RIPPLE REVEAL ---------- */
@keyframes kf-ripple{0%{clip-path:circle(0% at 20% 80%);opacity:.4}
100%{clip-path:circle(160% at 20% 80%);opacity:1}}
.anim-ripple-reveal{animation:kf-ripple 1.2s var(--anim-ease) both}
/* reduced motion */
@media (prefers-reduced-motion: reduce){
[class*="anim-"]{animation:none!important;transition:none!important}
}

View File

@ -0,0 +1,150 @@
/* html-ppt :: base.css — reset + shared tokens + layout primitives */
/* Default tokens. Themes in assets/themes/*.css override the :root block. */
:root {
--bg: #ffffff;
--bg-soft: #f7f7f8;
--surface: #ffffff;
--surface-2: #f2f2f4;
--border: rgba(0,0,0,.08);
--border-strong: rgba(0,0,0,.16);
--text-1: #111216;
--text-2: #55596a;
--text-3: #8a8f9e;
--accent: #3b6cff;
--accent-2: #7a5cff;
--accent-3: #ff5c8a;
--good: #1aaf6c;
--warn: #f5a524;
--bad: #e0445a;
--grad: linear-gradient(135deg,#3b6cff,#7a5cff 55%,#ff5c8a);
--grad-soft: linear-gradient(135deg,#eef2ff,#f5ecff 55%,#ffeef5);
--radius: 18px;
--radius-sm: 12px;
--radius-lg: 26px;
--shadow: 0 10px 30px rgba(18,24,40,.08), 0 2px 6px rgba(18,24,40,.04);
--shadow-lg: 0 24px 60px rgba(18,24,40,.14), 0 6px 16px rgba(18,24,40,.06);
--font-sans: 'Inter','Noto Sans SC',-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
--font-serif: 'Playfair Display','Noto Serif SC',Georgia,serif;
--font-mono: 'JetBrains Mono','IBM Plex Mono',SFMono-Regular,Menlo,monospace;
--font-display: var(--font-sans);
--letter-tight: -.03em;
--letter-normal: -.01em;
--ease: cubic-bezier(.4,0,.2,1);
}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--text-1);
font-family:var(--font-sans);font-weight:400;line-height:1.6;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
letter-spacing:var(--letter-normal)}
img,svg,video{max-width:100%;display:block}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
code,kbd,pre,samp{font-family:var(--font-mono)}
/* ================= SLIDE SYSTEM ================= */
.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:var(--bg)}
.slide{
position:absolute;inset:0;
display:flex;flex-direction:column;justify-content:center;
padding:72px 96px;
box-sizing:border-box;
opacity:0;pointer-events:none;
transition:opacity .5s var(--ease), transform .5s var(--ease);
transform:translateX(30px);
overflow:hidden;
}
.slide.is-active{opacity:1;pointer-events:auto;transform:translateX(0);z-index:2}
.slide.is-prev{transform:translateX(-30px)}
/* single-page standalone (used when a layout file is opened directly) */
body.single .slide{position:relative;width:100vw;height:100vh;opacity:1;transform:none;pointer-events:auto}
/* ================= TYPOGRAPHY ================= */
.eyebrow{font-size:13px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;color:var(--text-3)}
.kicker{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.08em;text-transform:uppercase}
h1.title,.h1{font-family:var(--font-display);font-size:72px;line-height:1.05;font-weight:800;letter-spacing:var(--letter-tight);margin:0 0 18px;color:var(--text-1)}
h2.title,.h2{font-family:var(--font-display);font-size:54px;line-height:1.1;font-weight:700;letter-spacing:var(--letter-tight);margin:0 0 14px}
h3,.h3{font-size:32px;line-height:1.2;font-weight:600;letter-spacing:var(--letter-normal);margin:0 0 10px}
h4,.h4{font-size:22px;line-height:1.3;font-weight:600;margin:0 0 8px}
.lede{font-size:22px;line-height:1.55;color:var(--text-2);font-weight:300;max-width:62ch}
.dim{color:var(--text-2)}
.dim2{color:var(--text-3)}
.mono{font-family:var(--font-mono)}
.serif{font-family:var(--font-serif)}
.gradient-text{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
/* ================= LAYOUT PRIMITIVES ================= */
.stack>*+*{margin-top:14px}
.row{display:flex;gap:24px;align-items:center}
.row.wrap{flex-wrap:wrap}
.grid{display:grid;gap:24px}
.g2{grid-template-columns:repeat(2,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
.g4{grid-template-columns:repeat(4,1fr)}
.center{display:flex;align-items:center;justify-content:center;text-align:center}
.fill{flex:1}
.sp-t{padding-top:24px}.sp-b{padding-bottom:24px}
.mt-s{margin-top:8px}.mt-m{margin-top:18px}.mt-l{margin-top:32px}
.mb-s{margin-bottom:8px}.mb-m{margin-bottom:18px}.mb-l{margin-bottom:32px}
/* ================= CARDS ================= */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:26px 28px;box-shadow:var(--shadow);position:relative;overflow:hidden}
.card-soft{background:var(--surface-2);border:1px solid var(--border)}
.card-outline{background:transparent;border:1.5px solid var(--border-strong);box-shadow:none}
.card-accent{background:var(--surface);border-top:3px solid var(--accent)}
.card-hover{transition:transform .3s var(--ease),box-shadow .3s var(--ease)}
.card-hover:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.pill{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:500;
background:var(--surface-2);color:var(--text-2);border:1px solid var(--border)}
.pill-accent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 28%,transparent)}
/* ================= BARS / DIVIDERS ================= */
.divider{height:1px;background:var(--border);width:100%}
.divider-accent{height:3px;width:72px;background:var(--accent);border-radius:2px}
/* ================= CHROME (header/footer/progress) ================= */
.deck-header{position:absolute;top:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;z-index:10;pointer-events:none}
.deck-footer{position:absolute;bottom:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);z-index:10;pointer-events:none}
.slide-number::before{content:attr(data-current)}
.slide-number::after{content:" / " attr(data-total)}
.progress-bar{position:fixed;left:0;right:0;bottom:0;height:3px;background:transparent;z-index:20}
.progress-bar > span{display:block;height:100%;width:0;background:var(--accent);transition:width .3s var(--ease)}
/* ================= PRESENTER / OVERVIEW ================= */
.notes{display:none!important}
.notes-overlay{position:fixed;inset:auto 0 0 0;max-height:42vh;background:rgba(20,22,30,.95);color:#e8ebf4;
padding:20px 32px;font-size:16px;line-height:1.6;border-top:1px solid rgba(255,255,255,.1);transform:translateY(100%);
transition:transform .3s var(--ease);z-index:40;overflow:auto;font-family:var(--font-sans)}
.notes-overlay.open{transform:translateY(0)}
.overview{position:fixed;inset:0;background:rgba(10,12,18,.92);backdrop-filter:blur(12px);z-index:50;
display:none;padding:40px;overflow:auto}
.overview.open{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;align-content:start}
.overview .thumb{background:var(--surface);border:1px solid var(--border);border-radius:12px;
aspect-ratio:16/9;overflow:hidden;cursor:pointer;position:relative;color:var(--text-1);padding:16px;
font-size:11px;transition:transform .2s var(--ease)}
.overview .thumb:hover{transform:scale(1.04)}
.overview .thumb .n{position:absolute;top:8px;left:10px;font-weight:700;font-size:14px;color:var(--text-3)}
.overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)}
/* ================= PRESENTER VIEW ================= */
/* Presenter view opens in a separate popup window (S key).
* All presenter styles are self-contained in the popup HTML generated by runtime.js.
* The audience window (this file) is NOT affected — it stays as normal deck view.
* Only the .notes class below is needed to hide speaker notes from audience. */
/* ================= UTILITY ================= */
.hidden{display:none!important}
.nowrap{white-space:nowrap}
.tr{text-align:right}.tc{text-align:center}.tl{text-align:left}
.uppercase{text-transform:uppercase;letter-spacing:.12em}
/* ================= PRINT ================= */
@media print{
.slide{position:relative;opacity:1!important;transform:none!important;page-break-after:always;height:100vh}
.deck-header,.deck-footer,.progress-bar,.notes-overlay,.overview{display:none!important}
}

View File

@ -0,0 +1,9 @@
/* html-ppt :: shared webfonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@200;300;400;500;600;700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,800;1,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');

View File

@ -0,0 +1,960 @@
/* html-ppt :: runtime.js
* Keyboard-driven deck runtime. Zero dependencies.
*
* Features:
* ← → / space / PgUp PgDn / Home End navigation
* F fullscreen
* S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
* The original window stays as audience view, synced via BroadcastChannel.
* Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout.
* N quick notes overlay (bottom drawer)
* O slide overview grid
* T cycle themes (reads data-themes on <html> or <body>)
* A cycle demo animation on current slide
* URL hash #/N deep-link to slide N (1-based)
* Progress bar auto-managed
*/
(function () {
'use strict';
const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in',
'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep',
'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt',
'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom',
'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal'];
function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
/* ========== Parse URL for preview-only mode ==========
* When loaded as iframe.src = "index.html?preview=3", runtime enters a
* locked single-slide mode: only slide N is visible, no chrome, no keys,
* no hash updates. This is how the presenter window shows pixel-perfect
* previews — by loading the actual deck file in an iframe and telling it
* to display only a specific slide.
*/
function getPreviewIdx() {
const m = /[?&]preview=(\d+)/.exec(location.search || '');
return m ? parseInt(m[1], 10) - 1 : -1;
}
ready(function () {
const deck = document.querySelector('.deck');
if (!deck) return;
const slides = Array.from(deck.querySelectorAll('.slide'));
if (!slides.length) return;
const previewOnlyIdx = getPreviewIdx();
const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
/* ===== Preview-only mode: show one slide, hide everything else ===== */
if (isPreviewMode) {
function showSlide(i) {
slides.forEach((s, j) => {
const active = (j === i);
s.classList.toggle('is-active', active);
s.style.display = active ? '' : 'none';
if (active) {
s.style.opacity = '1';
s.style.transform = 'none';
s.style.pointerEvents = 'auto';
}
});
}
showSlide(previewOnlyIdx);
/* Hide chrome that the presenter shouldn't see in preview */
const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
document.documentElement.setAttribute('data-preview', '1');
document.body.setAttribute('data-preview', '1');
/* Auto-detect theme base path for theme switching in preview mode */
function getPreviewThemeBase() {
const base = document.documentElement.getAttribute('data-theme-base');
if (base) return base;
const tl = document.getElementById('theme-link');
if (tl) {
const raw = tl.getAttribute('href') || '';
const ls = raw.lastIndexOf('/');
if (ls >= 0) return raw.substring(0, ls + 1);
}
return 'assets/themes/';
}
const previewThemeBase = getPreviewThemeBase();
/* Listen for postMessage from parent presenter window:
* - preview-goto: switch visible slide WITHOUT reloading
* - preview-theme: switch theme CSS link to match audience window */
window.addEventListener('message', function(e) {
if (!e.data) return;
if (e.data.type === 'preview-goto') {
const n = parseInt(e.data.idx, 10);
if (n >= 0 && n < slides.length) showSlide(n);
} else if (e.data.type === 'preview-theme' && e.data.name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = previewThemeBase + e.data.name + '.css';
document.documentElement.setAttribute('data-theme', e.data.name);
}
});
/* Signal to parent that preview iframe is ready */
try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
return;
}
let idx = 0;
const total = slides.length;
/* ===== BroadcastChannel for presenter sync ===== */
const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
let bc;
try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
// Are we running inside the presenter popup? (legacy flag, now unused)
const isPresenterWindow = false;
/* ===== progress bar ===== */
let bar = document.querySelector('.progress-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'progress-bar';
bar.innerHTML = '<span></span>';
document.body.appendChild(bar);
}
const barFill = bar.querySelector('span');
/* ===== notes overlay (N key) ===== */
let notes = document.querySelector('.notes-overlay');
if (!notes) {
notes = document.createElement('div');
notes.className = 'notes-overlay';
document.body.appendChild(notes);
}
/* ===== overview grid (O key) ===== */
let overview = document.querySelector('.overview');
if (!overview) {
overview = document.createElement('div');
overview.className = 'overview';
slides.forEach((s, i) => {
const t = document.createElement('div');
t.className = 'thumb';
// Force 16:9 aspect ratio robustly
t.style.padding = '0 0 56.25% 0';
t.style.height = '0';
t.style.position = 'relative';
t.style.overflow = 'hidden';
const title = s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
// Create a container for the mini-slide
const mini = document.createElement('div');
mini.className = 'mini-slide';
mini.style.position = 'absolute';
mini.style.top = '0';
mini.style.left = '0';
mini.style.width = '1920px';
mini.style.height = '1080px';
mini.style.transformOrigin = 'top left';
mini.style.pointerEvents = 'none';
mini.style.background = 'var(--bg)';
// Clone the slide content
const clone = s.cloneNode(true);
clone.className = 'slide is-active'; // force active styles
clone.style.position = 'absolute';
clone.style.inset = '0';
clone.style.transform = 'none';
clone.style.opacity = '1';
clone.style.padding = '72px 96px'; // ensure padding is kept
mini.appendChild(clone);
t.appendChild(mini);
// Add the number and title overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)';
overlay.style.color = '#fff';
overlay.style.zIndex = '10';
overlay.style.pointerEvents = 'none';
const n = document.createElement('div');
n.className = 'n';
n.textContent = i + 1;
n.style.position = 'absolute';
n.style.top = '12px';
n.style.left = '16px';
n.style.fontWeight = '700';
n.style.fontSize = '16px';
n.style.color = '#fff';
n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
const text = document.createElement('div');
text.className = 't';
text.textContent = title.trim().slice(0,80);
text.style.position = 'absolute';
text.style.bottom = '12px';
text.style.left = '16px';
text.style.right = '16px';
text.style.fontWeight = '600';
text.style.fontSize = '14px';
text.style.color = '#fff';
text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
overlay.appendChild(n);
overlay.appendChild(text);
t.appendChild(overlay);
t.addEventListener('click', () => { go(i); toggleOverview(false); });
overview.appendChild(t);
});
document.body.appendChild(overview);
}
/* ===== navigation ===== */
function go(n, fromRemote){
n = Math.max(0, Math.min(total-1, n));
slides.forEach((s,i) => {
s.classList.toggle('is-active', i===n);
s.classList.toggle('is-prev', i<n);
});
idx = n;
barFill.style.width = ((n+1)/total*100)+'%';
const numEl = document.querySelector('.slide-number');
if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
// notes (bottom overlay)
const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
notes.innerHTML = note ? note.innerHTML : '';
// hash
const hashTarget = '#/'+(n+1);
if (location.hash !== hashTarget && !isPresenterWindow) {
history.replaceState(null,'', hashTarget);
}
// re-trigger entry animations
slides[n].querySelectorAll('[data-anim]').forEach(el => {
const a = el.getAttribute('data-anim');
el.classList.remove('anim-'+a);
void el.offsetWidth;
el.classList.add('anim-'+a);
});
// counter-up
slides[n].querySelectorAll('.counter').forEach(el => {
const target = parseFloat(el.getAttribute('data-to')||el.textContent);
const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
const start = performance.now();
const from = 0;
function tick(now){
const t = Math.min(1,(now-start)/dur);
const v = from + (target-from)*(1-Math.pow(1-t,3));
el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
if (t<1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
// Broadcast to other window (audience ↔ presenter)
if (!fromRemote && bc) {
bc.postMessage({ type: 'go', idx: n });
}
}
/* ===== listen for remote navigation / theme changes ===== */
if (bc) {
bc.onmessage = function(e) {
if (!e.data) return;
if (e.data.type === 'go' && typeof e.data.idx === 'number') {
go(e.data.idx, true);
} else if (e.data.type === 'theme' && e.data.name) {
/* Sync theme across windows */
const i = themes.indexOf(e.data.name);
if (i >= 0) themeIdx = i;
applyTheme(e.data.name);
}
};
}
function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
function toggleOverview(force){
const isOpen = force!==undefined ? force : !overview.classList.contains('open');
overview.classList.toggle('open', isOpen);
if (isOpen) {
requestAnimationFrame(() => {
const thumbs = overview.querySelectorAll('.thumb');
if (thumbs.length) {
const scale = thumbs[0].clientWidth / 1920;
overview.querySelectorAll('.mini-slide').forEach(m => {
m.style.transform = 'scale(' + scale + ')';
});
}
});
}
}
/* ========== PRESENTER MODE — Magnetic-card popup window ========== */
/* Opens a new window with 4 draggable, resizable cards:
* CURRENT — iframe(?preview=N) pixel-perfect preview of current slide
* NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide
* SCRIPT — large speaker notes (逐字稿)
* TIMER — elapsed timer + page counter + controls
* Cards remember position/size in localStorage.
* Two windows sync via BroadcastChannel.
*/
let presenterWin = null;
function openPresenterWindow() {
if (presenterWin && !presenterWin.closed) {
presenterWin.focus();
return;
}
// Build absolute URL of THIS deck file (without hash/query)
const deckUrl = location.protocol + '//' + location.host + location.pathname;
// Collect slide titles + notes (HTML strings)
const slideMeta = slides.map((s, i) => {
const note = s.querySelector('.notes, aside.notes, .speaker-notes');
return {
title: s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
notes: note ? note.innerHTML : ''
};
});
/* Capture current theme so presenter previews match the audience */
const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
if (!presenterWin) {
alert('请允许弹出窗口以使用演讲者视图');
return;
}
presenterWin.document.open();
presenterWin.document.write(presenterHTML);
presenterWin.document.close();
}
function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
const metaJSON = JSON.stringify(slideMeta);
const deckUrlJSON = JSON.stringify(deckUrl);
const channelJSON = JSON.stringify(channelName);
const themeJSON = JSON.stringify(currentTheme || '');
const storageKey = 'html-ppt-presenter:' + location.pathname;
// Build the document as a single template string for clarity
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Presenter View</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #1a1d24;
background-image:
radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
}
/* Stage: positioned area where cards live */
#stage { position: absolute; inset: 0; overflow: hidden; }
/* Magnetic card */
.pcard {
position: absolute;
background: #0d1117;
border: 1px solid rgba(255,255,255,.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
display: flex; flex-direction: column;
overflow: hidden;
min-width: 180px; min-height: 100px;
transition: box-shadow .2s, border-color .2s;
}
.pcard.dragging { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(88,166,255,.5); border-color: #58a6ff; transition: none; z-index: 9999; }
.pcard.resizing { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(63,185,80,.5); border-color: #3fb950; transition: none; z-index: 9999; }
.pcard:hover { border-color: rgba(88,166,255,.3); }
/* Card header (drag handle) */
.pcard-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.06);
cursor: move;
user-select: none;
flex-shrink: 0;
}
.pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
.pcard-title {
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
font-weight: 700; color: #8b949e; flex: 1;
}
.pcard-meta { font-size: 11px; color: #6e7681; }
/* Card body */
.pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
/* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
.pcard-preview .pcard-body { background: #000; }
.pcard-preview iframe {
position: absolute; top: 0; left: 0;
width: 1920px; height: 1080px;
border: none;
transform-origin: top left;
pointer-events: none;
background: transparent;
}
.pcard-preview .preview-end {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #484f58; font-size: 14px; letter-spacing: .12em;
}
/* Notes card */
.pcard-notes .pcard-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 18px; line-height: 1.75;
color: #d0d7de;
font-family: "Noto Sans SC", -apple-system, sans-serif;
}
.pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
.pcard-notes .pcard-body strong { color: #f0883e; }
.pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
.pcard-notes .pcard-body code {
font-family: "SF Mono", monospace; font-size: .9em;
background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
}
.pcard-notes .empty { color: #484f58; font-style: italic; }
/* Timer card */
.pcard-timer .pcard-body {
display: flex; flex-direction: column; gap: 14px;
padding: 18px 20px; justify-content: center;
}
.timer-display {
font-family: "SF Mono", "JetBrains Mono", monospace;
font-size: 42px; font-weight: 700;
color: #3fb950;
letter-spacing: .04em;
line-height: 1;
}
.timer-row {
display: flex; align-items: center; gap: 12px;
font-size: 14px; color: #8b949e;
}
.timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
.timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
.timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
.timer-btn {
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
.timer-btn:active { transform: translateY(1px); }
/* Resize handle */
.pcard-resize {
position: absolute; right: 0; bottom: 0;
width: 18px; height: 18px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 60%, transparent 60%, transparent 70%, rgba(255,255,255,.25) 70%, rgba(255,255,255,.25) 80%, transparent 80%);
z-index: 5;
}
.pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
/* Bottom hint bar */
.hint-bar {
position: fixed; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,.6);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255,255,255,.08);
padding: 6px 16px;
font-size: 11px; color: #8b949e;
display: flex; gap: 18px; align-items: center;
z-index: 1000;
}
.hint-bar kbd {
background: rgba(255,255,255,.08);
padding: 1px 6px; border-radius: 3px;
font-family: "SF Mono", monospace;
font-size: 10px;
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
}
.hint-bar .reset-layout {
margin-left: auto;
background: transparent; border: 1px solid rgba(255,255,255,.15);
color: #8b949e; padding: 3px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; font-family: inherit;
}
.hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
body.is-dragging-card * { user-select: none !important; }
body.is-dragging-card iframe { pointer-events: none !important; }
</style>
</head>
<body>
<div id="stage">
<div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">CURRENT</span>
<span class="pcard-meta" id="cur-meta">—</span>
</div>
<div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">NEXT</span>
<span class="pcard-meta" id="nxt-meta">—</span>
</div>
<div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
</div>
<div class="pcard-body" id="notes-body"></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">TIMER</span>
</div>
<div class="pcard-body">
<div class="timer-display" id="timer-display">00:00</div>
<div class="timer-row">
<span class="label">Slide</span>
<span class="val" id="timer-count">1 / ${total}</span>
</div>
<div class="timer-controls">
<button class="timer-btn" id="btn-prev">← Prev</button>
<button class="timer-btn" id="btn-next">Next →</button>
<button class="timer-btn" id="btn-reset">⏱ Reset</button>
</div>
</div>
<div class="pcard-resize" data-resize></div>
</div>
</div>
<div class="hint-bar">
<span><kbd>← →</kbd> 翻页</span>
<span><kbd>R</kbd> 重置计时</span>
<span><kbd>Esc</kbd> 关闭</span>
<span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
<button class="reset-layout" id="reset-layout">重置布局</button>
</div>
<script>
(function(){
var slideMeta = ${metaJSON};
var total = ${total};
var idx = ${startIdx};
var deckUrl = ${deckUrlJSON};
var STORAGE_KEY = ${JSON.stringify(storageKey)};
var bc;
try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
var iframeCur = document.getElementById('iframe-cur');
var iframeNxt = document.getElementById('iframe-nxt');
var notesBody = document.getElementById('notes-body');
var curMeta = document.getElementById('cur-meta');
var nxtMeta = document.getElementById('nxt-meta');
var timerDisplay = document.getElementById('timer-display');
var timerCount = document.getElementById('timer-count');
/* ===== Default card layout ===== */
function defaultLayout() {
var w = window.innerWidth;
var h = window.innerHeight - 36; /* leave room for hint bar */
return {
'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
'card-nxt': { x: Math.round(w*0.55) + 8, y: 16, w: w - Math.round(w*0.55) - 24, h: Math.round(h*0.42) - 16 },
'card-notes': { x: Math.round(w*0.55) + 8, y: Math.round(h*0.42) + 8, w: w - Math.round(w*0.55) - 24, h: h - Math.round(h*0.42) - 16 },
'card-timer': { x: 16, y: Math.round(h*0.62) + 8, w: Math.round(w*0.55) - 24, h: h - Math.round(h*0.62) - 16 }
};
}
/* ===== Apply / save / restore layout ===== */
function applyLayout(layout) {
Object.keys(layout).forEach(function(id){
var el = document.getElementById(id);
var l = layout[id];
if (el && l) {
el.style.left = l.x + 'px';
el.style.top = l.y + 'px';
el.style.width = l.w + 'px';
el.style.height = l.h + 'px';
}
});
rescaleAll();
}
function readLayout() {
try {
var saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch(e) {}
return defaultLayout();
}
function saveLayout() {
var layout = {};
['card-cur','card-nxt','card-notes','card-timer'].forEach(function(id){
var el = document.getElementById(id);
if (el) {
layout[id] = {
x: parseInt(el.style.left,10) || 0,
y: parseInt(el.style.top,10) || 0,
w: parseInt(el.style.width,10) || 300,
h: parseInt(el.style.height,10) || 200
};
}
});
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
}
/* ===== iframe rescale to fit card body ===== */
function rescaleIframe(iframe) {
if (!iframe || iframe.style.display === 'none') return;
var body = iframe.parentElement;
var cw = body.clientWidth, ch = body.clientHeight;
if (!cw || !ch) return;
var s = Math.min(cw / 1920, ch / 1080);
iframe.style.transform = 'scale(' + s + ')';
/* Center the scaled iframe in the body */
var sw = 1920 * s, sh = 1080 * s;
iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
}
function rescaleAll() {
rescaleIframe(iframeCur);
rescaleIframe(iframeNxt);
}
window.addEventListener('resize', rescaleAll);
/* ===== Drag (move card by header) ===== */
document.querySelectorAll('[data-drag]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault();
card.classList.add('dragging');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startL = parseInt(card.style.left,10) || 0;
var startT = parseInt(card.style.top,10) || 0;
function onMove(ev){
var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
card.style.left = nx + 'px';
card.style.top = ny + 'px';
}
function onUp(){
card.classList.remove('dragging');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Resize (drag bottom-right corner) ===== */
document.querySelectorAll('[data-resize]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault(); e.stopPropagation();
card.classList.add('resizing');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startW = parseInt(card.style.width,10) || card.offsetWidth;
var startH = parseInt(card.style.height,10) || card.offsetHeight;
function onMove(ev){
var nw = Math.max(180, startW + ev.clientX - startX);
var nh = Math.max(100, startH + ev.clientY - startY);
card.style.width = nw + 'px';
card.style.height = nh + 'px';
if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
}
function onUp(){
card.classList.remove('resizing');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
rescaleAll();
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Preview iframe ready tracking =====
* Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
* slide changes are sent via postMessage('preview-goto') so the iframe
* just toggles visibility of a different .slide — no reload, no flicker.
*/
var iframeReady = { cur: false, nxt: false };
var currentTheme = ${themeJSON};
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'preview-ready') return;
var iframe = null;
if (e.source === iframeCur.contentWindow) {
iframeReady.cur = true;
iframe = iframeCur;
postPreviewGoto(iframeCur, idx);
} else if (e.source === iframeNxt.contentWindow) {
iframeReady.nxt = true;
iframe = iframeNxt;
postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
}
/* Sync current theme to the iframe */
if (iframe && currentTheme) {
try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
}
if (iframe) rescaleIframe(iframe);
});
function postPreviewGoto(iframe, n) {
try {
iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
} catch(e) {}
}
/* ===== Update content =====
* Smooth (no-reload) navigation: send postMessage to iframes instead of
* resetting src. Iframes stay loaded, just switch visible .slide.
*/
function update(n) {
n = Math.max(0, Math.min(total - 1, n));
idx = n;
/* Current preview — postMessage (smooth) */
if (iframeReady.cur) postPreviewGoto(iframeCur, n);
curMeta.textContent = (n + 1) + '/' + total;
/* Next preview */
if (n + 1 < total) {
iframeNxt.style.display = '';
var endEl = document.querySelector('#card-nxt .preview-end');
if (endEl) endEl.remove();
if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
nxtMeta.textContent = (n + 2) + '/' + total;
} else {
iframeNxt.style.display = 'none';
var body = document.querySelector('#card-nxt .pcard-body');
if (body && !body.querySelector('.preview-end')) {
var end = document.createElement('div');
end.className = 'preview-end';
end.textContent = '— END OF DECK —';
body.appendChild(end);
}
nxtMeta.textContent = 'END';
}
/* Notes */
var note = slideMeta[n].notes;
notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
/* Timer count */
timerCount.textContent = (n + 1) + ' / ' + total;
}
/* ===== Timer ===== */
var tStart = Date.now();
setInterval(function(){
var s = Math.floor((Date.now() - tStart) / 1000);
var mm = String(Math.floor(s/60)).padStart(2,'0');
var ss = String(s%60).padStart(2,'0');
timerDisplay.textContent = mm + ':' + ss;
}, 1000);
function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
/* ===== BroadcastChannel sync ===== */
if (bc) {
bc.onmessage = function(e){
if (!e.data) return;
if (e.data.type === 'go') update(e.data.idx);
else if (e.data.type === 'theme' && e.data.name) {
currentTheme = e.data.name;
/* Forward theme change to preview iframes */
[iframeCur, iframeNxt].forEach(function(iframe){
try {
iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
} catch(err) {}
});
}
};
}
function go(n) {
update(n);
if (bc) bc.postMessage({ type: 'go', idx: idx });
}
/* ===== Buttons ===== */
document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
document.getElementById('btn-reset').addEventListener('click', resetTimer);
document.getElementById('reset-layout').addEventListener('click', function(){
if (confirm('恢复默认卡片布局?')) {
try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
applyLayout(defaultLayout());
}
});
/* ===== Keyboard ===== */
document.addEventListener('keydown', function(e){
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch(e.key) {
case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total - 1); break;
case 'r': case 'R': resetTimer(); break;
case 'Escape': window.close(); break;
}
});
/* ===== Iframe load → rescale (catches initial size) ===== */
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
/* ===== Init =====
* Load each iframe ONCE with the deck file. After they post
* 'preview-ready', all subsequent navigation is via postMessage
* (smooth, no reload, no flicker).
*/
applyLayout(readLayout());
iframeCur.src = deckUrl + '?preview=' + (idx + 1);
if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
/* Initialize notes/timer/count without touching iframes */
notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
curMeta.textContent = (idx + 1) + '/' + total;
nxtMeta.textContent = (idx + 2) + '/' + total;
timerCount.textContent = (idx + 1) + ' / ' + total;
})();
</` + `script>
</body></html>`;
}
function fullscreen(){ const el=document.documentElement;
if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
else document.exitFullscreen&&document.exitFullscreen();
}
// theme cycling
const root = document.documentElement;
const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
let themeIdx = 0;
// Auto-detect theme base path from existing <link id="theme-link">
let themeBase = root.getAttribute('data-theme-base');
if (!themeBase) {
const existingLink = document.getElementById('theme-link');
if (existingLink) {
// el.getAttribute('href') gives the raw relative path written in HTML
const rawHref = existingLink.getAttribute('href') || '';
const lastSlash = rawHref.lastIndexOf('/');
themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
} else {
themeBase = 'assets/themes/';
}
}
function applyTheme(name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = themeBase + name + '.css';
root.setAttribute('data-theme', name);
const ind = document.querySelector('.theme-indicator');
if (ind) ind.textContent = name;
}
function cycleTheme(fromRemote){
if (!themes.length) return;
themeIdx = (themeIdx+1) % themes.length;
const name = themes[themeIdx];
applyTheme(name);
/* Broadcast to other window (audience ↔ presenter) */
if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
}
// animation cycling on current slide
let animIdx = 0;
function cycleAnim(){
animIdx = (animIdx+1) % ANIMS.length;
const a = ANIMS[animIdx];
const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
ANIMS.forEach(x => target.classList.remove('anim-'+x));
void target.offsetWidth;
target.classList.add('anim-'+a);
target.setAttribute('data-anim', a);
const ind = document.querySelector('.anim-indicator');
if (ind) ind.textContent = a;
}
document.addEventListener('keydown', function (e) {
if (e.metaKey||e.ctrlKey||e.altKey) return;
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total-1); break;
case 'f': case 'F': fullscreen(); break;
case 's': case 'S': openPresenterWindow(); break;
case 'n': case 'N': toggleNotes(); break;
case 'o': case 'O': toggleOverview(); break;
case 't': case 'T': cycleTheme(); break;
case 'a': case 'A': cycleAnim(); break;
case 'Escape': toggleOverview(false); toggleNotes(false); break;
}
});
// hash deep-link
function fromHash(){
const m = /^#\/(\d+)/.exec(location.hash||'');
if (m) go(Math.max(0, parseInt(m[1],10)-1));
}
window.addEventListener('hashchange', fromHash);
fromHash();
go(idx);
});
})();

View File

@ -0,0 +1,23 @@
/* theme: academic-paper — 学术论文 */
:root{
--bg:#fdfcf8;--bg-soft:#f7f5ed;--surface:#ffffff;--surface-2:#f5f3ea;
--border:rgba(20,20,20,.14);--border-strong:rgba(20,20,20,.35);
--text-1:#0a0a0a;--text-2:#333333;--text-3:#707070;
--accent:#1a3a7a;--accent-2:#0a0a0a;--accent-3:#8a1a1a;
--good:#1a5a2a;--warn:#8a6a1a;--bad:#8a1a1a;
--grad:linear-gradient(135deg,#1a3a7a,#0a0a0a);
--grad-soft:linear-gradient(135deg,#e8edf8,#f5f3ea);
--radius:0px;--radius-sm:0px;--radius-lg:0px;
--shadow:none;
--shadow-lg:0 1px 2px rgba(0,0,0,.1);
--font-sans:'Latin Modern Roman','Playfair Display','Noto Serif SC',Georgia,serif;
--font-serif:'Latin Modern Roman','Playfair Display','Noto Serif SC',Georgia,serif;
--font-display:'Latin Modern Roman','Playfair Display','Noto Serif SC',Georgia,serif;
}
body{font-family:var(--font-serif)}
h1.title,h2.title,.h1,.h2{font-weight:700;font-family:var(--font-serif)}
.card{border:1px solid var(--border);box-shadow:none}
.divider{background:var(--text-1);height:1px}
.divider-accent{background:var(--text-1);height:2px;width:100%}
a{color:var(--accent);text-decoration:underline}
.kicker{color:var(--accent);font-style:italic;text-transform:none;letter-spacing:0;font-weight:400}

View File

@ -0,0 +1,14 @@
/* theme: arctic-cool — 冷色调 蓝/青/石板灰 */
:root{
--bg:#f2f6fb;--bg-soft:#e7eef7;--surface:#ffffff;--surface-2:#edf3fa;
--border:rgba(40,70,110,.12);--border-strong:rgba(40,70,110,.24);
--text-1:#0e1f33;--text-2:#3a5778;--text-3:#6b819b;
--accent:#1e6fb0;--accent-2:#17b1b1;--accent-3:#6f8aa6;
--good:#1aaf84;--warn:#d19030;--bad:#c5485a;
--grad:linear-gradient(135deg,#1e6fb0,#17b1b1 60%,#5fb9d6);
--grad-soft:linear-gradient(135deg,#e7eef7,#dff3f3);
--radius:14px;--radius-sm:10px;--radius-lg:22px;
--shadow:0 10px 28px rgba(40,70,110,.12);
--shadow-lg:0 24px 60px rgba(40,70,110,.18);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,20 @@
/* theme: aurora — 极光渐变 */
:root{
--bg:#06091c;--bg-soft:#0a1130;--surface:rgba(255,255,255,.05);--surface-2:rgba(255,255,255,.08);
--border:rgba(180,220,255,.14);--border-strong:rgba(180,220,255,.28);
--text-1:#e8f0ff;--text-2:#b4c4e4;--text-3:#6a7a9e;
--accent:#5ef2c6;--accent-2:#7aa2ff;--accent-3:#c984ff;
--good:#5ef2c6;--warn:#ffd27a;--bad:#ff8ab0;
--grad:linear-gradient(135deg,#5ef2c6,#7aa2ff 50%,#c984ff);
--grad-soft:linear-gradient(135deg,rgba(94,242,198,.2),rgba(201,132,255,.2));
--radius:20px;--radius-sm:14px;--radius-lg:28px;
--shadow:0 20px 60px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.08);
--shadow-lg:0 30px 80px rgba(0,0,0,.55);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}
body{background:
radial-gradient(60% 50% at 20% 10%,rgba(94,242,198,.35),transparent 70%),
radial-gradient(55% 50% at 80% 20%,rgba(122,162,255,.32),transparent 70%),
radial-gradient(70% 60% at 50% 100%,rgba(201,132,255,.3),transparent 70%),
#06091c}
.card{backdrop-filter:blur(24px) saturate(160%);-webkit-backdrop-filter:blur(24px) saturate(160%)}

View File

@ -0,0 +1,16 @@
/* theme: bauhaus — 几何+原色 */
:root{
--bg:#f4efe3;--bg-soft:#e8e2d1;--surface:#ffffff;--surface-2:#f4efe3;
--border:#111111;--border-strong:#111111;
--text-1:#111111;--text-2:#333333;--text-3:#666666;
--accent:#e03c27;--accent-2:#f4c430;--accent-3:#1d4eaf;
--good:#1b8c3c;--warn:#f4c430;--bad:#e03c27;
--grad:linear-gradient(135deg,#e03c27 0 33%,#f4c430 33% 66%,#1d4eaf 66% 100%);
--grad-soft:linear-gradient(135deg,#f4efe3,#e8e2d1);
--radius:0;--radius-sm:0;--radius-lg:0;
--shadow:4px 4px 0 #111;--shadow-lg:8px 8px 0 #111;
--font-sans:'Space Grotesk','Inter','Noto Sans SC',sans-serif;
--font-display:'Archivo Black',sans-serif;
--letter-tight:-.03em;
}
.card{border:2px solid #111}

View File

@ -0,0 +1,19 @@
/* theme: blueprint — 蓝图工程 */
:root{
--bg:#0b3a6f;--bg-soft:#0a3260;--surface:rgba(255,255,255,.06);--surface-2:rgba(255,255,255,.1);
--border:rgba(190,220,255,.3);--border-strong:rgba(190,220,255,.55);
--text-1:#e8f3ff;--text-2:#b8d4f0;--text-3:#7da8cf;
--accent:#ffffff;--accent-2:#aee1ff;--accent-3:#ffd27a;
--good:#8ef0a6;--warn:#ffd27a;--bad:#ff8a96;
--grad:linear-gradient(135deg,#ffffff,#aee1ff);
--grad-soft:linear-gradient(135deg,#0a3260,#0b3a6f);
--radius:2px;--radius-sm:2px;--radius-lg:4px;
--shadow:none;--shadow-lg:0 16px 40px rgba(0,0,0,.3);
--font-sans:'JetBrains Mono','IBM Plex Mono',monospace;
--font-display:'JetBrains Mono',monospace;
}
body{background:
linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px) 0 0/40px 40px,
linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px) 0 0/40px 40px,
#0b3a6f}
.card{border:1px dashed rgba(190,220,255,.45);background:rgba(255,255,255,.04)}

View File

@ -0,0 +1,14 @@
/* theme: catppuccin-latte — catppuccin 浅 */
:root{
--bg:#eff1f5;--bg-soft:#e6e9ef;--surface:#ffffff;--surface-2:#eef0f4;
--border:rgba(76,79,105,.14);--border-strong:rgba(76,79,105,.28);
--text-1:#4c4f69;--text-2:#6c6f85;--text-3:#9ca0b0;
--accent:#8839ef;--accent-2:#1e66f5;--accent-3:#ea76cb;
--good:#40a02b;--warn:#df8e1d;--bad:#d20f39;
--grad:linear-gradient(135deg,#8839ef,#1e66f5 50%,#04a5e5);
--grad-soft:linear-gradient(135deg,#eff1f5,#e6e9ef);
--radius:14px;--radius-sm:10px;--radius-lg:22px;
--shadow:0 8px 24px rgba(76,79,105,.1);
--shadow-lg:0 20px 56px rgba(76,79,105,.16);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,14 @@
/* theme: catppuccin-mocha — catppuccin 深 */
:root{
--bg:#1e1e2e;--bg-soft:#181825;--surface:#313244;--surface-2:#45475a;
--border:rgba(205,214,244,.12);--border-strong:rgba(205,214,244,.24);
--text-1:#cdd6f4;--text-2:#a6adc8;--text-3:#7f849c;
--accent:#cba6f7;--accent-2:#89b4fa;--accent-3:#f5c2e7;
--good:#a6e3a1;--warn:#f9e2af;--bad:#f38ba8;
--grad:linear-gradient(135deg,#cba6f7,#89b4fa 50%,#94e2d5);
--grad-soft:linear-gradient(135deg,#313244,#45475a);
--radius:14px;--radius-sm:10px;--radius-lg:22px;
--shadow:0 10px 30px rgba(0,0,0,.35);
--shadow-lg:0 24px 60px rgba(0,0,0,.5);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,19 @@
/* theme: corporate-clean — 企业商务 */
:root{
--bg:#ffffff;--bg-soft:#f5f7fa;--surface:#ffffff;--surface-2:#f0f3f7;
--border:rgba(10,37,64,.12);--border-strong:rgba(10,37,64,.28);
--text-1:#0a2540;--text-2:#425466;--text-3:#8898aa;
--accent:#0a2540;--accent-2:#1d4ed8;--accent-3:#64748b;
--good:#0e9f6e;--warn:#d97706;--bad:#dc2626;
--grad:linear-gradient(135deg,#0a2540,#1d4ed8);
--grad-soft:linear-gradient(135deg,#f0f4fb,#e4ecf7);
--radius:6px;--radius-sm:4px;--radius-lg:10px;
--shadow:0 1px 3px rgba(10,37,64,.08),0 4px 12px rgba(10,37,64,.05);
--shadow-lg:0 4px 12px rgba(10,37,64,.1),0 16px 40px rgba(10,37,64,.08);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'Inter','Noto Sans SC',sans-serif;
}
.card{border:1px solid var(--border)}
.divider-accent{background:var(--accent);height:3px;width:56px}
.kicker{color:var(--accent-2)}
h1.title,h2.title,.h1,.h2{font-weight:700;color:var(--accent)}

View File

@ -0,0 +1,23 @@
/* theme: cyberpunk-neon — 赛博朋克霓虹 */
:root{
--bg:#000000;--bg-soft:#0a0a12;--surface:#0f0f1a;--surface-2:#14141f;
--border:rgba(255,0,170,.25);--border-strong:rgba(0,240,255,.55);
--text-1:#f5f7ff;--text-2:#b4b8d4;--text-3:#6b6e8a;
--accent:#ff2bd6;--accent-2:#00f0ff;--accent-3:#f9f871;
--good:#39ff14;--warn:#f9f871;--bad:#ff2bd6;
--grad:linear-gradient(135deg,#ff2bd6,#7a00ff 50%,#00f0ff);
--grad-soft:linear-gradient(135deg,rgba(255,43,214,.18),rgba(0,240,255,.18));
--radius:6px;--radius-sm:3px;--radius-lg:10px;
--shadow:0 0 0 1px rgba(255,43,214,.35),0 0 24px rgba(255,43,214,.35),0 0 48px rgba(0,240,255,.18);
--shadow-lg:0 0 0 1px rgba(0,240,255,.5),0 0 40px rgba(0,240,255,.45),0 0 80px rgba(255,43,214,.3);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'JetBrains Mono','IBM Plex Mono',monospace;
}
body{background:
radial-gradient(ellipse at 15% 0%,rgba(255,43,214,.22),transparent 60%),
radial-gradient(ellipse at 85% 100%,rgba(0,240,255,.2),transparent 60%),
#000}
h1.title,h2.title,.h1,.h2{text-shadow:0 0 12px rgba(255,43,214,.6),0 0 30px rgba(0,240,255,.35)}
.kicker{color:var(--accent-2);text-shadow:0 0 8px rgba(0,240,255,.6)}
.card{background:rgba(15,15,26,.72);backdrop-filter:blur(8px)}
.divider-accent{background:var(--grad);box-shadow:0 0 12px var(--accent)}

View File

@ -0,0 +1,14 @@
/* theme: dracula — dracula 深色 */
:root{
--bg:#282a36;--bg-soft:#21222c;--surface:#343746;--surface-2:#44475a;
--border:rgba(248,248,242,.12);--border-strong:rgba(248,248,242,.24);
--text-1:#f8f8f2;--text-2:#bdbde0;--text-3:#6272a4;
--accent:#bd93f9;--accent-2:#ff79c6;--accent-3:#8be9fd;
--good:#50fa7b;--warn:#f1fa8c;--bad:#ff5555;
--grad:linear-gradient(135deg,#bd93f9,#ff79c6 55%,#8be9fd);
--grad-soft:linear-gradient(135deg,#343746,#44475a);
--radius:12px;--radius-sm:8px;--radius-lg:18px;
--shadow:0 10px 30px rgba(0,0,0,.4);
--shadow-lg:0 22px 60px rgba(0,0,0,.55);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,18 @@
/* theme: editorial-serif — 杂志风衬线,高级 */
:root{
--bg:#faf7f2;--bg-soft:#f3efe6;--surface:#ffffff;--surface-2:#f7f2e8;
--border:rgba(40,28,18,.12);--border-strong:rgba(40,28,18,.24);
--text-1:#1b1410;--text-2:#5c4a3e;--text-3:#8a7868;
--accent:#8a2a1c;--accent-2:#c97a4a;--accent-3:#1b1410;
--good:#3f7d4f;--warn:#b07a1f;--bad:#8a2a1c;
--grad:linear-gradient(135deg,#8a2a1c,#c97a4a);
--grad-soft:linear-gradient(135deg,#faf7f2,#f3efe6);
--radius:4px;--radius-sm:2px;--radius-lg:8px;
--shadow:0 2px 12px rgba(40,28,18,.06);
--shadow-lg:0 20px 50px rgba(40,28,18,.14);
--font-sans:'Playfair Display','Noto Serif SC',serif;
--font-display:'Playfair Display','Noto Serif SC',serif;
--font-serif:'Playfair Display','Noto Serif SC',serif;
--letter-tight:-.02em;
}
.h1,.h2,h1.title,h2.title{font-style:italic;font-weight:600}

View File

@ -0,0 +1,26 @@
/* theme: engineering-whiteprint — 工程白图 */
:root{
--bg:#ffffff;--bg-soft:#f8fafc;--surface:#ffffff;--surface-2:#f4f7fb;
--border:rgba(10,30,70,.22);--border-strong:#0a1e46;
--text-1:#0a1e46;--text-2:#3a4a6a;--text-3:#8090a8;
--accent:#0a1e46;--accent-2:#1e5ac4;--accent-3:#c42a10;
--good:#1a6a3a;--warn:#c47a10;--bad:#c42a10;
--grad:linear-gradient(135deg,#0a1e46,#1e5ac4);
--grad-soft:linear-gradient(135deg,#eaf0fb,#f4f7fb);
--radius:0px;--radius-sm:0px;--radius-lg:0px;
--shadow:none;
--shadow-lg:0 0 0 1px var(--border-strong);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-mono:'JetBrains Mono','IBM Plex Mono',monospace;
--font-display:'JetBrains Mono','Inter',monospace;
}
body{background:
repeating-linear-gradient(0deg,rgba(10,30,70,.07) 0 1px,transparent 1px 40px),
repeating-linear-gradient(90deg,rgba(10,30,70,.07) 0 1px,transparent 1px 40px),
#ffffff}
.card{border:1px solid var(--border-strong);box-shadow:none;background:rgba(255,255,255,.85)}
.divider{background:var(--border-strong);height:1px}
.divider-accent{background:var(--border-strong);height:1px;width:100%}
.kicker{font-family:var(--font-mono);color:var(--accent-2);letter-spacing:.18em}
h1.title,h2.title,.h1,.h2{font-weight:600}
.pill{font-family:var(--font-mono);border:1px solid var(--border-strong);border-radius:0}

View File

@ -0,0 +1,21 @@
/* theme: glassmorphism — 毛玻璃 */
:root{
--bg:#0b1024;--bg-soft:#0e1530;--surface:rgba(255,255,255,.06);--surface-2:rgba(255,255,255,.1);
--border:rgba(255,255,255,.14);--border-strong:rgba(255,255,255,.28);
--text-1:#f2f4ff;--text-2:#c3c8e6;--text-3:#8287a8;
--accent:#7dd3fc;--accent-2:#c084fc;--accent-3:#f0abfc;
--good:#86efac;--warn:#fde68a;--bad:#fca5a5;
--grad:linear-gradient(135deg,#7dd3fc,#c084fc 55%,#f0abfc);
--grad-soft:linear-gradient(135deg,rgba(125,211,252,.18),rgba(192,132,252,.18));
--radius:22px;--radius-sm:14px;--radius-lg:30px;
--shadow:0 20px 60px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);
--shadow-lg:0 30px 80px rgba(0,0,0,.5);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}
body{background:
radial-gradient(60% 60% at 20% 20%,rgba(125,211,252,.3),transparent 60%),
radial-gradient(50% 50% at 80% 30%,rgba(192,132,252,.28),transparent 60%),
radial-gradient(60% 60% at 60% 90%,rgba(240,171,252,.25),transparent 60%),
#0b1024}
.card{backdrop-filter:blur(28px) saturate(180%);-webkit-backdrop-filter:blur(28px) saturate(180%);
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.18)}

View File

@ -0,0 +1,14 @@
/* theme: gruvbox-dark */
:root{
--bg:#282828;--bg-soft:#1d2021;--surface:#3c3836;--surface-2:#504945;
--border:rgba(235,219,178,.14);--border-strong:rgba(235,219,178,.28);
--text-1:#ebdbb2;--text-2:#d5c4a1;--text-3:#928374;
--accent:#fabd2f;--accent-2:#fe8019;--accent-3:#b8bb26;
--good:#b8bb26;--warn:#fabd2f;--bad:#fb4934;
--grad:linear-gradient(135deg,#fe8019,#fabd2f 55%,#b8bb26);
--grad-soft:linear-gradient(135deg,#3c3836,#504945);
--radius:6px;--radius-sm:4px;--radius-lg:12px;
--shadow:0 10px 30px rgba(0,0,0,.5);
--shadow-lg:0 24px 60px rgba(0,0,0,.65);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,21 @@
/* theme: japanese-minimal — 和风极简 */
:root{
--bg:#fafaf5;--bg-soft:#f2f0e6;--surface:#ffffff;--surface-2:#f5f3ea;
--border:rgba(40,30,20,.1);--border-strong:rgba(40,30,20,.3);
--text-1:#1a1a18;--text-2:#5c564c;--text-3:#9c958a;
--accent:#d93a2a;--accent-2:#1a1a18;--accent-3:#c9a961;
--good:#4a6b3e;--warn:#c9a961;--bad:#d93a2a;
--grad:linear-gradient(135deg,#d93a2a,#1a1a18);
--grad-soft:linear-gradient(135deg,#faeae6,#f5f3ea);
--radius:0px;--radius-sm:0px;--radius-lg:2px;
--shadow:none;
--shadow-lg:0 1px 0 rgba(40,30,20,.12);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-serif:'Noto Serif SC','Playfair Display',serif;
--font-display:'Noto Serif SC','Playfair Display',serif;
}
h1.title,h2.title,.h1,.h2{font-weight:500;letter-spacing:.04em}
.card{border:1px solid var(--border);box-shadow:none;padding:36px 40px}
.divider-accent{background:var(--accent);height:2px;width:48px}
.kicker{color:var(--accent);letter-spacing:.2em}
.slide{padding:96px 128px}

View File

@ -0,0 +1,21 @@
/* theme: magazine-bold — 杂志大标题 */
:root{
--bg:#f5efe2;--bg-soft:#ebe4d2;--surface:#fbf6e8;--surface-2:#ede5d0;
--border:rgba(10,10,10,.16);--border-strong:#0a0a0a;
--text-1:#0a0a0a;--text-2:#2a2a2a;--text-3:#6a6458;
--accent:#ea5a1a;--accent-2:#0a0a0a;--accent-3:#c42a10;
--good:#2a6a2a;--warn:#ea5a1a;--bad:#c42a10;
--grad:linear-gradient(135deg,#ea5a1a,#c42a10);
--grad-soft:linear-gradient(135deg,#fbe4d0,#f5d6c0);
--radius:0px;--radius-sm:0px;--radius-lg:2px;
--shadow:none;
--shadow-lg:6px 6px 0 var(--accent);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-serif:'Playfair Display','Noto Serif SC',Georgia,serif;
--font-display:'Playfair Display','Noto Serif SC',Georgia,serif;
}
h1.title,.h1{font-size:120px;line-height:.92;font-weight:900;letter-spacing:-.04em;font-family:var(--font-serif)}
h2.title,.h2{font-size:72px;font-weight:800;font-family:var(--font-serif)}
.card{border:1.5px solid var(--text-1)}
.divider-accent{background:var(--accent);height:6px;width:90px}
.kicker{color:var(--accent);text-transform:uppercase;font-weight:700;letter-spacing:.25em}

View File

@ -0,0 +1,20 @@
/* theme: memphis-pop — 孟菲斯波普 */
:root{
--bg:#fef6e8;--bg-soft:#fdebc7;--surface:#ffffff;--surface-2:#fff1d1;
--border:#111111;--border-strong:#111111;
--text-1:#111111;--text-2:#333333;--text-3:#666666;
--accent:#ff3d8b;--accent-2:#37c2d7;--accent-3:#ffcc00;
--good:#6ac04c;--warn:#ffcc00;--bad:#ff3d8b;
--grad:linear-gradient(135deg,#ff3d8b,#ffcc00 50%,#37c2d7);
--grad-soft:linear-gradient(135deg,#fdebc7,#fff1d1);
--radius:10px;--radius-sm:6px;--radius-lg:18px;
--shadow:5px 5px 0 #111;--shadow-lg:9px 9px 0 #111;
--font-sans:'Space Grotesk','Inter','Noto Sans SC',sans-serif;
--font-display:'Archivo Black',sans-serif;
}
.card{border:2.5px solid #111}
body{background-image:
radial-gradient(circle at 10% 20%,#ff3d8b 3px,transparent 4px),
radial-gradient(circle at 80% 40%,#37c2d7 3px,transparent 4px),
radial-gradient(circle at 30% 80%,#ffcc00 3px,transparent 4px);
background-size:200px 200px,220px 220px,260px 260px}

View File

@ -0,0 +1,19 @@
/* theme: midcentury — 世纪中期现代 */
:root{
--bg:#f3ead8;--bg-soft:#ebdfc4;--surface:#f9f2e0;--surface-2:#e8dcbe;
--border:rgba(60,40,20,.18);--border-strong:rgba(60,40,20,.4);
--text-1:#201810;--text-2:#5a4830;--text-3:#9a8868;
--accent:#d4902a;--accent-2:#2a7a7f;--accent-3:#c7502a;
--good:#5a7a3a;--warn:#d4902a;--bad:#c7502a;
--grad:linear-gradient(135deg,#d4902a,#c7502a 55%,#2a7a7f);
--grad-soft:linear-gradient(135deg,#f4e0b6,#eac7a8);
--radius:2px;--radius-sm:0px;--radius-lg:4px;
--shadow:4px 4px 0 rgba(40,25,10,.12);
--shadow-lg:6px 6px 0 rgba(40,25,10,.2),0 10px 24px rgba(40,25,10,.14);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'Playfair Display','Noto Serif SC',serif;
}
.card{border:1.5px solid var(--border-strong)}
.divider-accent{background:var(--accent-3);height:4px;width:80px}
.kicker{color:var(--accent-2)}
h1.title,.h1{color:var(--accent-3)}

View File

@ -0,0 +1,16 @@
/* theme: minimal-white — 极简白,克制高级 */
:root{
--bg:#ffffff;--bg-soft:#fafafa;--surface:#ffffff;--surface-2:#f5f5f6;
--border:rgba(17,18,22,.08);--border-strong:rgba(17,18,22,.16);
--text-1:#0c0d10;--text-2:#55596a;--text-3:#9ca1b0;
--accent:#111216;--accent-2:#3b3f4a;--accent-3:#6b6f7a;
--good:#1aaf6c;--warn:#c98500;--bad:#c13a3a;
--grad:linear-gradient(135deg,#111216,#3b3f4a);
--grad-soft:linear-gradient(135deg,#f5f5f6,#ffffff);
--radius:14px;--radius-sm:8px;--radius-lg:22px;
--shadow:0 1px 2px rgba(17,18,22,.04),0 8px 24px rgba(17,18,22,.06);
--shadow-lg:0 20px 60px rgba(17,18,22,.1);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'Inter','Noto Sans SC',sans-serif;
--letter-tight:-.035em;
}

View File

@ -0,0 +1,17 @@
/* theme: neo-brutalism — 厚描边、硬阴影、明黄 */
:root{
--bg:#fffef0;--bg-soft:#fffbd0;--surface:#ffffff;--surface-2:#fff38a;
--border:#000000;--border-strong:#000000;
--text-1:#000000;--text-2:#222222;--text-3:#555555;
--accent:#ffd400;--accent-2:#ff5ca8;--accent-3:#3a7cff;
--good:#00b36b;--warn:#ff9900;--bad:#ff3a30;
--grad:linear-gradient(135deg,#ffd400,#ff5ca8);
--grad-soft:linear-gradient(135deg,#fffbd0,#fff);
--radius:6px;--radius-sm:4px;--radius-lg:10px;
--shadow:6px 6px 0 #000;--shadow-lg:10px 10px 0 #000;
--font-sans:'Space Grotesk','Inter','Noto Sans SC',sans-serif;
--font-display:'Archivo Black','Space Grotesk',sans-serif;
--letter-tight:-.03em;
}
.card{border:3px solid #000}
.pill{border:2px solid #000;background:#ffd400;color:#000}

View File

@ -0,0 +1,20 @@
/* theme: news-broadcast — 新闻播报 */
:root{
--bg:#ffffff;--bg-soft:#f4f4f4;--surface:#ffffff;--surface-2:#ececec;
--border:rgba(0,0,0,.14);--border-strong:#0a0a0a;
--text-1:#0a0a0a;--text-2:#3a3a3a;--text-3:#7a7a7a;
--accent:#e11d2d;--accent-2:#0a0a0a;--accent-3:#ffd100;
--good:#0e7c3a;--warn:#ffd100;--bad:#e11d2d;
--grad:linear-gradient(90deg,#e11d2d 0%,#e11d2d 100%);
--grad-soft:linear-gradient(135deg,#fde5e7,#f4f4f4);
--radius:0px;--radius-sm:0px;--radius-lg:2px;
--shadow:none;
--shadow-lg:0 4px 0 var(--accent);
--font-sans:'Oswald','Inter','Noto Sans SC',sans-serif;
--font-display:'Oswald','Inter','Noto Sans SC',sans-serif;
}
h1.title,h2.title,.h1,.h2{font-weight:700;text-transform:uppercase;letter-spacing:-.01em}
.card{border:2px solid var(--text-1);box-shadow:6px 6px 0 var(--accent)}
.divider-accent{background:var(--accent);height:6px;width:100%}
.kicker{background:var(--accent);color:#fff;padding:4px 12px;display:inline-block;letter-spacing:.15em}
.slide::before{content:"";position:absolute;left:0;top:0;bottom:0;width:8px;background:var(--accent);z-index:3}

View File

@ -0,0 +1,14 @@
/* theme: nord */
:root{
--bg:#2e3440;--bg-soft:#272b35;--surface:#3b4252;--surface-2:#434c5e;
--border:rgba(236,239,244,.12);--border-strong:rgba(236,239,244,.24);
--text-1:#eceff4;--text-2:#d8dee9;--text-3:#7b8394;
--accent:#88c0d0;--accent-2:#81a1c1;--accent-3:#b48ead;
--good:#a3be8c;--warn:#ebcb8b;--bad:#bf616a;
--grad:linear-gradient(135deg,#88c0d0,#81a1c1 50%,#b48ead);
--grad-soft:linear-gradient(135deg,#3b4252,#434c5e);
--radius:12px;--radius-sm:8px;--radius-lg:20px;
--shadow:0 10px 30px rgba(0,0,0,.35);
--shadow-lg:0 22px 60px rgba(0,0,0,.5);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,21 @@
/* theme: pitch-deck-vc — YC 风融资 pitch */
:root{
--bg:#ffffff;--bg-soft:#fafbfc;--surface:#ffffff;--surface-2:#f5f7fa;
--border:rgba(20,30,50,.1);--border-strong:rgba(20,30,50,.22);
--text-1:#0b0d12;--text-2:#4a5270;--text-3:#8b93a8;
--accent:#0070f3;--accent-2:#7928ca;--accent-3:#ff4ecb;
--good:#0cce6b;--warn:#f5a524;--bad:#ee0000;
--grad:linear-gradient(135deg,#0070f3,#7928ca);
--grad-soft:linear-gradient(135deg,#e8f0ff,#f3e8ff);
--radius:14px;--radius-sm:8px;--radius-lg:22px;
--shadow:0 2px 8px rgba(20,30,50,.06),0 12px 32px rgba(20,30,50,.06);
--shadow-lg:0 8px 24px rgba(20,30,50,.1),0 30px 80px rgba(20,30,50,.1);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'Inter','Noto Sans SC',sans-serif;
}
.slide{padding:88px 120px}
h1.title,.h1{font-weight:800;letter-spacing:-.035em}
h1.title .gradient-text,.h1 .gradient-text{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.card{border:1px solid var(--border)}
.divider-accent{background:var(--grad);height:4px;width:64px;border-radius:2px}
.kicker{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}

View File

@ -0,0 +1,16 @@
/* theme: rainbow-gradient — 彩虹渐变点缀(白底) */
:root{
--bg:#ffffff;--bg-soft:#f8f8fb;--surface:#ffffff;--surface-2:#f4f4f8;
--border:rgba(20,20,40,.08);--border-strong:rgba(20,20,40,.2);
--text-1:#0c0d10;--text-2:#4d5162;--text-3:#9096a8;
--accent:#ff4d8b;--accent-2:#7a5cff;--accent-3:#36b6ff;
--good:#1aaf6c;--warn:#f5a524;--bad:#e0445a;
--grad:linear-gradient(90deg,#ff0080,#ff4d00,#ff9900,#ffe600,#00c853,#0091ea,#6200ea,#ff0080);
--grad-soft:linear-gradient(135deg,#fff,#f8f8fb);
--radius:16px;--radius-sm:10px;--radius-lg:24px;
--shadow:0 12px 32px rgba(124,92,255,.1);
--shadow-lg:0 24px 60px rgba(124,92,255,.18);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}
.gradient-text{background-size:200% auto;animation:rbflow 6s linear infinite}
@keyframes rbflow{to{background-position:200% 0}}

View File

@ -0,0 +1,22 @@
/* theme: retro-tv — 复古显像管 */
:root{
--bg:#f5ecd7;--bg-soft:#efe4c6;--surface:#fbf5e2;--surface-2:#efe3c2;
--border:rgba(120,70,20,.22);--border-strong:rgba(120,70,20,.45);
--text-1:#2a1a08;--text-2:#6b4a22;--text-3:#a68656;
--accent:#e67e14;--accent-2:#c73a1f;--accent-3:#f2b544;
--good:#3e8940;--warn:#e67e14;--bad:#c73a1f;
--grad:linear-gradient(135deg,#c73a1f,#e67e14 55%,#f2b544);
--grad-soft:linear-gradient(135deg,#fde6c4,#fbd9a0);
--radius:10px;--radius-sm:6px;--radius-lg:16px;
--shadow:0 6px 0 rgba(80,40,0,.12),0 12px 28px rgba(80,40,0,.15);
--shadow-lg:0 10px 0 rgba(80,40,0,.15),0 24px 50px rgba(80,40,0,.2);
--font-sans:'Inter','Noto Sans SC',sans-serif;
--font-display:'Playfair Display','Noto Serif SC',serif;
}
body{background:
repeating-linear-gradient(0deg,rgba(80,40,0,.06) 0 2px,transparent 2px 4px),
radial-gradient(ellipse at center,#f7ecd0 0%,#e8d9b0 85%,#c9b888 100%)}
.slide::before{content:"";position:absolute;inset:0;pointer-events:none;
background:repeating-linear-gradient(0deg,rgba(0,0,0,.035) 0 2px,transparent 2px 4px);z-index:1}
.slide > *{position:relative;z-index:2}
h1.title,.h1{color:var(--accent-2)}

View File

@ -0,0 +1,14 @@
/* theme: rose-pine */
:root{
--bg:#191724;--bg-soft:#1f1d2e;--surface:#26233a;--surface-2:#2a2740;
--border:rgba(224,222,244,.12);--border-strong:rgba(224,222,244,.24);
--text-1:#e0def4;--text-2:#c4b8d8;--text-3:#6e6a86;
--accent:#ebbcba;--accent-2:#c4a7e7;--accent-3:#9ccfd8;
--good:#31748f;--warn:#f6c177;--bad:#eb6f92;
--grad:linear-gradient(135deg,#ebbcba,#c4a7e7 55%,#9ccfd8);
--grad-soft:linear-gradient(135deg,#26233a,#2a2740);
--radius:14px;--radius-sm:10px;--radius-lg:22px;
--shadow:0 10px 30px rgba(0,0,0,.4);
--shadow-lg:0 22px 58px rgba(0,0,0,.55);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,17 @@
/* theme: sharp-mono — 锐利黑白高对比 */
:root{
--bg:#ffffff;--bg-soft:#ffffff;--surface:#ffffff;--surface-2:#000000;
--border:#000000;--border-strong:#000000;
--text-1:#000000;--text-2:#1a1a1a;--text-3:#4a4a4a;
--accent:#000000;--accent-2:#000000;--accent-3:#ff2200;
--good:#008800;--warn:#ff9900;--bad:#ff0000;
--grad:linear-gradient(135deg,#000,#222);
--grad-soft:linear-gradient(135deg,#fff,#eee);
--radius:0;--radius-sm:0;--radius-lg:0;
--shadow:4px 4px 0 #000;--shadow-lg:8px 8px 0 #000;
--font-sans:'Archivo Black','Inter','Noto Sans SC',sans-serif;
--font-display:'Archivo Black',sans-serif;
--letter-tight:-.04em;
}
.h1,.h2,h1.title,h2.title{text-transform:uppercase}
.card{border:2px solid #000}

View File

@ -0,0 +1,14 @@
/* theme: soft-pastel — 柔和马卡龙 */
:root{
--bg:#fdf7fb;--bg-soft:#fbeef3;--surface:#ffffff;--surface-2:#fdf0f5;
--border:rgba(120,70,110,.12);--border-strong:rgba(120,70,110,.22);
--text-1:#3a1f33;--text-2:#6b4d62;--text-3:#a28a99;
--accent:#f49bb8;--accent-2:#b5d5f0;--accent-3:#f7d08a;
--good:#9dd9a3;--warn:#f7d08a;--bad:#ef9a9a;
--grad:linear-gradient(135deg,#f49bb8,#b5d5f0 55%,#c4a0e8);
--grad-soft:linear-gradient(135deg,#fbeef3,#eaf4fc);
--radius:24px;--radius-sm:16px;--radius-lg:32px;
--shadow:0 8px 28px rgba(244,155,184,.18);
--shadow-lg:0 24px 70px rgba(181,213,240,.3);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,14 @@
/* theme: solarized-light */
:root{
--bg:#fdf6e3;--bg-soft:#eee8d5;--surface:#ffffff;--surface-2:#f5efd7;
--border:rgba(88,110,117,.2);--border-strong:rgba(88,110,117,.4);
--text-1:#073642;--text-2:#586e75;--text-3:#93a1a1;
--accent:#268bd2;--accent-2:#2aa198;--accent-3:#d33682;
--good:#859900;--warn:#b58900;--bad:#dc322f;
--grad:linear-gradient(135deg,#268bd2,#2aa198 50%,#859900);
--grad-soft:linear-gradient(135deg,#fdf6e3,#eee8d5);
--radius:10px;--radius-sm:6px;--radius-lg:16px;
--shadow:0 6px 20px rgba(88,110,117,.14);
--shadow-lg:0 18px 50px rgba(88,110,117,.24);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,14 @@
/* theme: sunset-warm — 暖色调 橘/珊瑚/琥珀 */
:root{
--bg:#fff7ef;--bg-soft:#ffeedc;--surface:#ffffff;--surface-2:#fff2e0;
--border:rgba(120,60,20,.12);--border-strong:rgba(120,60,20,.22);
--text-1:#2a160a;--text-2:#6b4630;--text-3:#a28572;
--accent:#e36a2d;--accent-2:#f2a341;--accent-3:#d94860;
--good:#5ea35a;--warn:#f2a341;--bad:#d94860;
--grad:linear-gradient(135deg,#d94860,#e36a2d 50%,#f2a341);
--grad-soft:linear-gradient(135deg,#ffeedc,#ffe0d0);
--radius:18px;--radius-sm:12px;--radius-lg:28px;
--shadow:0 12px 32px rgba(227,106,45,.16);
--shadow-lg:0 24px 64px rgba(227,106,45,.22);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,17 @@
/* theme: swiss-grid — 瑞士网格Helvetica 感 */
:root{
--bg:#ffffff;--bg-soft:#f4f4f4;--surface:#ffffff;--surface-2:#f4f4f4;
--border:#111111;--border-strong:#111111;
--text-1:#111111;--text-2:#444444;--text-3:#888888;
--accent:#d6001c;--accent-2:#111111;--accent-3:#888888;
--good:#0f8a2f;--warn:#d38a00;--bad:#d6001c;
--grad:linear-gradient(135deg,#d6001c,#111);
--grad-soft:linear-gradient(135deg,#f4f4f4,#fff);
--radius:0;--radius-sm:0;--radius-lg:0;
--shadow:none;--shadow-lg:none;
--font-sans:'Inter','Helvetica Neue',Helvetica,'Noto Sans SC',sans-serif;
--font-display:'Inter','Helvetica Neue',Helvetica,sans-serif;
--letter-tight:-.04em;
}
.card{border-top:2px solid #111;border-bottom:1px solid #111;border-left:none;border-right:none;box-shadow:none;background:#fff}
.slide{background-image:linear-gradient(90deg,rgba(0,0,0,.04) 1px,transparent 1px);background-size:calc(100%/12) 100%}

View File

@ -0,0 +1,18 @@
/* theme: terminal-green — 绿屏终端 */
:root{
--bg:#030a04;--bg-soft:#041308;--surface:#0a1b10;--surface-2:#0d2614;
--border:rgba(0,255,120,.22);--border-strong:rgba(0,255,120,.42);
--text-1:#8cff9a;--text-2:#4bd17a;--text-3:#2f8a4d;
--accent:#00ff88;--accent-2:#67ffd0;--accent-3:#b6ff6b;
--good:#00ff88;--warn:#ffe066;--bad:#ff6464;
--grad:linear-gradient(135deg,#00ff88,#67ffd0);
--grad-soft:linear-gradient(135deg,#0a1b10,#0d2614);
--radius:4px;--radius-sm:2px;--radius-lg:8px;
--shadow:0 0 30px rgba(0,255,136,.15);
--shadow-lg:0 0 60px rgba(0,255,136,.28);
--font-sans:'JetBrains Mono','IBM Plex Mono',monospace;
--font-display:'JetBrains Mono',monospace;
--letter-tight:-.01em;
}
body{text-shadow:0 0 2px rgba(0,255,136,.5)}
.card{border:1px solid rgba(0,255,120,.3);background:rgba(10,27,16,.6)}

View File

@ -0,0 +1,14 @@
/* theme: tokyo-night */
:root{
--bg:#1a1b26;--bg-soft:#16161e;--surface:#24283b;--surface-2:#2f334d;
--border:rgba(192,202,245,.12);--border-strong:rgba(192,202,245,.24);
--text-1:#c0caf5;--text-2:#a9b1d6;--text-3:#565f89;
--accent:#7aa2f7;--accent-2:#bb9af7;--accent-3:#7dcfff;
--good:#9ece6a;--warn:#e0af68;--bad:#f7768e;
--grad:linear-gradient(135deg,#7aa2f7,#bb9af7 55%,#f7768e);
--grad-soft:linear-gradient(135deg,#24283b,#2f334d);
--radius:12px;--radius-sm:8px;--radius-lg:20px;
--shadow:0 10px 30px rgba(0,0,0,.45);
--shadow-lg:0 24px 62px rgba(0,0,0,.6);
--font-sans:'Inter','Noto Sans SC',sans-serif;
}

View File

@ -0,0 +1,21 @@
/* theme: vaporwave — 蒸汽波 */
:root{
--bg:#1a0938;--bg-soft:#261050;--surface:rgba(255,255,255,.06);--surface-2:rgba(255,255,255,.1);
--border:rgba(255,110,199,.28);--border-strong:rgba(0,245,255,.5);
--text-1:#fdf0ff;--text-2:#d4a8e8;--text-3:#8a6ba8;
--accent:#ff6ec7;--accent-2:#00f5ff;--accent-3:#ffd166;
--grad:linear-gradient(135deg,#ff6ec7 0%,#c94fff 35%,#00f5ff 100%);
--grad-soft:linear-gradient(135deg,rgba(255,110,199,.25),rgba(0,245,255,.25));
--radius:18px;--radius-sm:10px;--radius-lg:28px;
--shadow:0 20px 60px rgba(255,110,199,.2),0 0 1px rgba(0,245,255,.6);
--shadow-lg:0 30px 80px rgba(255,110,199,.3),0 0 2px rgba(0,245,255,.8);
--font-sans:'Space Grotesk','Inter','Noto Sans SC',sans-serif;
--font-display:'Space Grotesk','Inter',sans-serif;
}
body{background:
linear-gradient(180deg,#1a0938 0%,#3a0f5c 45%,#7a1f6b 85%,#e85d9c 100%),
radial-gradient(ellipse at 50% 80%,rgba(0,245,255,.3),transparent 60%)}
h1.title,.h1{background:var(--grad);-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;color:transparent}
.card{backdrop-filter:blur(18px)}
.divider-accent{background:var(--grad);height:4px;width:120px;box-shadow:0 0 20px var(--accent)}

Some files were not shown because too many files have changed in this diff Show More