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): 修复节点证据评估中需求验证逻辑 更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证, 只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
@ -9,6 +9,7 @@ from beaver.engine.providers import ProviderBundle
|
||||
from beaver.tasks.evidence import EvidenceBuilder, evaluate_node_evidence
|
||||
|
||||
from .models import DelegationEnvelope, NodeRunResult
|
||||
from .runtime_defaults import DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS
|
||||
|
||||
|
||||
class LocalAgentRunner:
|
||||
@ -55,7 +56,11 @@ class LocalAgentRunner:
|
||||
pinned_skill_names=envelope.inherited_pinned_skills,
|
||||
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
|
||||
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,
|
||||
)
|
||||
loaded = target_loop.boot()
|
||||
@ -70,7 +75,8 @@ class LocalAgentRunner:
|
||||
envelope.required_evidence,
|
||||
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:
|
||||
completion_status = "failed"
|
||||
elif evidence_gaps:
|
||||
@ -81,7 +87,10 @@ class LocalAgentRunner:
|
||||
if completion_status == "partial":
|
||||
error = "; ".join(evidence_gaps)
|
||||
else:
|
||||
error = None if success else (result.output_text or result.finish_reason)
|
||||
if raw_tool_call_output:
|
||||
error = "finalized output is a raw tool call"
|
||||
else:
|
||||
error = None if success else (result.output_text or result.finish_reason)
|
||||
return NodeRunResult(
|
||||
node_id=envelope.node_id or envelope.agent.name,
|
||||
success=success,
|
||||
@ -169,3 +178,16 @@ class LocalAgentRunner:
|
||||
"If no published skill matches, return [] and let the node continue without skills."
|
||||
)
|
||||
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 = (
|
||||
"<||DSML||tool_calls>",
|
||||
"<||DSML||invoke",
|
||||
"<tool_call",
|
||||
"<function=",
|
||||
)
|
||||
return any(marker in text for marker in markers)
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
"""Runtime defaults shared by Beaver team planning and execution."""
|
||||
|
||||
DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS = 100
|
||||
@ -48,8 +48,6 @@ class SkillContext:
|
||||
content_hash: str = ""
|
||||
activation_reason: str = "selected"
|
||||
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)
|
||||
|
||||
@ -106,6 +106,7 @@ class EngineLoadResult:
|
||||
task_execution_planner: TaskExecutionPlanner | None = None
|
||||
mcp_manager: MCPConnectionManager | None = None
|
||||
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)
|
||||
closed: bool = False
|
||||
|
||||
@ -317,10 +318,7 @@ class EngineLoader:
|
||||
draft_service=draft_service,
|
||||
)
|
||||
task_service = self._task_service or TaskService(workspace / "tasks")
|
||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(
|
||||
task_skill_resolver=task_skill_resolver,
|
||||
tool_registry=tool_registry,
|
||||
)
|
||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner()
|
||||
mcp_manager = MCPConnectionManager(
|
||||
self.config.tools.mcp_servers,
|
||||
authz_config=self.config.authz,
|
||||
|
||||
@ -53,7 +53,7 @@ class AgentProfile:
|
||||
max_tokens: int | None = None
|
||||
max_context_messages: int = 1000
|
||||
temperature: float = 0.2
|
||||
max_tool_iterations: int = 30
|
||||
max_tool_iterations: int = 100
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -99,8 +99,8 @@ class _WebSearchLoopGuard:
|
||||
return None
|
||||
|
||||
query = str(payload.get("query") or self._last_query or "").strip()
|
||||
is_low_quality = payload.get("success") is False or payload.get("quality") == "low"
|
||||
if not is_low_quality:
|
||||
is_failed_search = payload.get("success") is False
|
||||
if not is_failed_search:
|
||||
self._reset()
|
||||
self._last_query = query
|
||||
return None
|
||||
@ -435,7 +435,9 @@ class AgentLoop:
|
||||
if include_tools and mcp_manager is not None:
|
||||
started_at = perf_counter()
|
||||
try:
|
||||
loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
|
||||
if not loaded.mcp_connected:
|
||||
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()]
|
||||
finally:
|
||||
add_latency("mcp_ms", started_at)
|
||||
@ -752,6 +754,11 @@ class AgentLoop:
|
||||
"memory_store": memory_service.get_store(),
|
||||
"tool_registry": tool_registry,
|
||||
"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),
|
||||
"beaver_config": loaded.config,
|
||||
"task_id": task_id,
|
||||
@ -764,6 +771,7 @@ class AgentLoop:
|
||||
"session_id": resolved_session_id,
|
||||
"task_id": task_id,
|
||||
"run_id": resolved_run_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
"allowed_tool_names": (
|
||||
None if allowed_tool_names is None else list(allowed_tool_names)
|
||||
),
|
||||
|
||||
@ -29,6 +29,7 @@ LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
||||
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
|
||||
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
|
||||
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
|
||||
"local_team_workflow_mcp": {"category": "team_workflow", "display_name": "本地 Agent Team Workflow 工具"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ LOCAL_TOOL_CATEGORIES = {
|
||||
"coordination": "Beaver Local Coordination Tools",
|
||||
"scheduler": "Beaver Local Scheduler 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(WebSearchTool()),
|
||||
]
|
||||
elif category == "team_workflow":
|
||||
from beaver.team_workflows.mcp_tools import create_team_workflow_tools
|
||||
|
||||
tools = create_team_workflow_tools()
|
||||
else:
|
||||
raise ValueError(f"Unknown local tool category: {category}")
|
||||
return tools, context
|
||||
|
||||
@ -68,7 +68,7 @@ class AgentService:
|
||||
self.profile.max_tokens = None
|
||||
self.profile.temperature = 0.2
|
||||
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:
|
||||
self.profile.max_tokens = max(1, defaults.max_tokens)
|
||||
if defaults.temperature is not None:
|
||||
|
||||
@ -17,6 +17,7 @@ class SessionProcessProjector:
|
||||
runs: dict[str, dict[str, Any]] = {}
|
||||
events: list[dict[str, Any]] = []
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
projected_skill_activation_run_ids: set[str] = set()
|
||||
|
||||
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"}:
|
||||
team_success = bool(payload.get("team_success"))
|
||||
root["status"] = "running"
|
||||
@ -203,7 +236,7 @@ class SessionProcessProjector:
|
||||
actor_type="system",
|
||||
actor_id="team",
|
||||
actor_name="Task Team",
|
||||
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
||||
text="Team completed" if team_success else "Team 执行未完成 / 子节点失败",
|
||||
created_at=created_at,
|
||||
status="done" if team_success else "error",
|
||||
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
|
||||
@ -316,7 +349,10 @@ class SessionProcessProjector:
|
||||
"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(
|
||||
event_id=_event_id(record, "synthesis-skills"),
|
||||
run_id=main_run_id,
|
||||
@ -326,7 +362,7 @@ class SessionProcessProjector:
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
|
||||
created_at=created_at,
|
||||
created_at=skill_created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
@ -439,6 +475,48 @@ def _activated_skill_reasons(run_record: Any | None) -> list[str]:
|
||||
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:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict):
|
||||
|
||||
@ -140,8 +140,6 @@ class SkillAssembler:
|
||||
content_hash=record.content_hash or "" if record is not None else "",
|
||||
activation_reason="llm_selected",
|
||||
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
|
||||
|
||||
@ -28,7 +28,6 @@ from .utils import (
|
||||
check_requirements,
|
||||
escape_xml,
|
||||
extract_required_tool_names,
|
||||
extract_skill_team_template,
|
||||
get_missing_requirements,
|
||||
parse_frontmatter,
|
||||
parse_skill_metadata_blob,
|
||||
@ -50,8 +49,6 @@ class SkillRecord:
|
||||
tool_hints: list[str] = field(default_factory=list)
|
||||
frontmatter: dict[str, Any] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
team_template: dict[str, Any] | None = None
|
||||
team_template_warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
@ -116,7 +113,6 @@ class SkillsLoader:
|
||||
continue
|
||||
normalized_frontmatter = dict(frontmatter)
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
template_result = extract_skill_team_template(body)
|
||||
record = SkillRecord(
|
||||
name=name,
|
||||
path=skill_file,
|
||||
@ -131,8 +127,6 @@ class SkillsLoader:
|
||||
),
|
||||
frontmatter=normalized_frontmatter,
|
||||
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):
|
||||
continue
|
||||
@ -152,7 +146,6 @@ class SkillsLoader:
|
||||
else:
|
||||
path = self.workspace_skills / name / "versions" / loaded.version.version / "SKILL.md"
|
||||
_frontmatter, body = parse_frontmatter(loaded.content)
|
||||
template_result = extract_skill_team_template(body)
|
||||
record = SkillRecord(
|
||||
name=name,
|
||||
path=path,
|
||||
@ -167,8 +160,6 @@ class SkillsLoader:
|
||||
),
|
||||
frontmatter=dict(loaded.version.frontmatter),
|
||||
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):
|
||||
continue
|
||||
|
||||
@ -17,7 +17,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@ -85,27 +84,6 @@ def strip_frontmatter(content: str) -> str:
|
||||
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]:
|
||||
"""从 canonical skill 正文的 `## Required Tools` 段落提取工具名。
|
||||
|
||||
|
||||
@ -5,12 +5,11 @@ from __future__ import annotations
|
||||
from time import perf_counter
|
||||
from typing import Any, Callable
|
||||
|
||||
from beaver.coordinator.models import ExecutionNode, TeamRunResult
|
||||
from beaver.engine import AgentRunResult
|
||||
from beaver.engine.context import SkillContext
|
||||
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 .planner import TaskExecutionPlan
|
||||
|
||||
@ -46,7 +45,7 @@ class TaskAttemptOrchestrator:
|
||||
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)
|
||||
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
|
||||
|
||||
attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
|
||||
@ -87,75 +86,17 @@ class TaskAttemptOrchestrator:
|
||||
**plan.to_event_payload(),
|
||||
},
|
||||
)
|
||||
team_summaries: list[str] = []
|
||||
team_execution_context = ""
|
||||
team_result: TeamRunResult | None = None
|
||||
if plan.is_team:
|
||||
team_result, team_error = await self._run_team_for_task(
|
||||
plan,
|
||||
task=task,
|
||||
parent_session_id=kwargs["session_id"],
|
||||
provider_bundle_factory=team_provider_bundle_factory
|
||||
or self._build_team_provider_bundle_factory(kwargs),
|
||||
plan = TaskExecutionPlan.single(
|
||||
"legacy_planner_team_ignored",
|
||||
planner_adaptation=plan.planner_adaptation,
|
||||
)
|
||||
if team_result is not None:
|
||||
team_summaries = [self._team_summary_for_validation(team_result)]
|
||||
team_packet = TaskEvidencePacket(
|
||||
task_id=task.task_id,
|
||||
attempt_index=attempt_index,
|
||||
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)
|
||||
outcome_metadata = {
|
||||
"task_outcome": "single",
|
||||
"incomplete_node_ids": [],
|
||||
"node_statuses": {},
|
||||
"evidence_gaps": {},
|
||||
}
|
||||
|
||||
attempt_kwargs = dict(kwargs)
|
||||
attempt_kwargs.update(
|
||||
@ -171,22 +112,15 @@ class TaskAttemptOrchestrator:
|
||||
attempt_kwargs["execution_context"] = self._join_context(
|
||||
base_execution_context,
|
||||
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(
|
||||
task=task,
|
||||
user_message=message,
|
||||
attempt_index=attempt_index,
|
||||
plan=plan,
|
||||
team_summaries=team_summaries,
|
||||
)
|
||||
|
||||
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(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
@ -210,7 +144,6 @@ class TaskAttemptOrchestrator:
|
||||
task=task,
|
||||
attempt_index=attempt_index,
|
||||
result=result,
|
||||
team_result=team_result,
|
||||
)
|
||||
evidence_text = render_task_evidence(evidence_packet)
|
||||
evidence_debug = {
|
||||
@ -256,31 +189,6 @@ class TaskAttemptOrchestrator:
|
||||
result.validation_result = None
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@ -396,7 +304,6 @@ class TaskAttemptOrchestrator:
|
||||
user_message: str,
|
||||
attempt_index: int,
|
||||
plan: TaskExecutionPlan | None = None,
|
||||
team_summaries: list[str] | None = None,
|
||||
) -> str:
|
||||
phase = f"attempt_{attempt_index}"
|
||||
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))
|
||||
if team_summaries:
|
||||
sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
|
||||
sections.append(
|
||||
"Skill selection instruction:\n"
|
||||
"Prefer reusing previously activated skills when they still match the Task. "
|
||||
@ -476,140 +381,6 @@ class TaskAttemptOrchestrator:
|
||||
def _join_context(*parts: str | None) -> str:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@ -617,7 +388,6 @@ class TaskAttemptOrchestrator:
|
||||
task: TaskRecord,
|
||||
attempt_index: int,
|
||||
result: AgentRunResult,
|
||||
team_result: TeamRunResult | None,
|
||||
) -> TaskEvidencePacket:
|
||||
main_run = EvidenceBuilder(session_manager).build_run_evidence(
|
||||
result.session_id,
|
||||
@ -629,67 +399,7 @@ class TaskAttemptOrchestrator:
|
||||
task_id=task.task_id,
|
||||
attempt_index=attempt_index,
|
||||
main_run=main_run,
|
||||
team_runs=self._team_run_evidence(team_result),
|
||||
team_node_results=list(team_result.node_results) if team_result is not None else [],
|
||||
team_runs=[],
|
||||
team_node_results=[],
|
||||
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
|
||||
|
||||
@ -155,7 +155,10 @@ def evaluate_node_evidence(
|
||||
if not output_text.strip():
|
||||
_append_unique(gaps, "missing required evidence: output")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
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.providers import ProviderBundle
|
||||
from beaver.tools.registry import ToolRegistry
|
||||
|
||||
from .models import TaskRecord
|
||||
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
||||
from .skill_resolver import SkillResolutionReport
|
||||
|
||||
|
||||
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:
|
||||
return os.getenv("BEAVER_AGENT_TEAM_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
@ -96,37 +84,7 @@ class TaskExecutionPlan:
|
||||
|
||||
|
||||
class TaskExecutionPlanner:
|
||||
"""Plan whether a Task attempt should run through a team first."""
|
||||
|
||||
_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
|
||||
"""Return the current Task execution mode for the root AgentLoop."""
|
||||
|
||||
async def plan(
|
||||
self,
|
||||
@ -144,122 +102,7 @@ class TaskExecutionPlanner:
|
||||
return TaskExecutionPlan.single("planner_disabled_by_environment")
|
||||
if not self._needs_team_planning(task=task, user_message=user_message):
|
||||
return TaskExecutionPlan.single("planner_skipped_simple_task")
|
||||
|
||||
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}")
|
||||
return TaskExecutionPlan.single("planner_team_replaced_by_workflow_tools")
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
2
app-instance/backend/beaver/team_workflows/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Local team workflow graph builders."""
|
||||
|
||||
@ -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
|
||||
273
app-instance/backend/beaver/team_workflows/base.py
Normal 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
|
||||
26
app-instance/backend/beaver/team_workflows/concurrent.py
Normal 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},
|
||||
)
|
||||
174
app-instance/backend/beaver/team_workflows/executor.py
Normal 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
|
||||
],
|
||||
}
|
||||
45
app-instance/backend/beaver/team_workflows/graph.py
Normal 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,
|
||||
)
|
||||
261
app-instance/backend/beaver/team_workflows/mcp_tools.py
Normal 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,
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
29
app-instance/backend/beaver/team_workflows/sequential.py
Normal 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,
|
||||
)
|
||||
@ -68,6 +68,15 @@ class MCPToolWrapper(BaseTool):
|
||||
)
|
||||
|
||||
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:
|
||||
result = await asyncio.wait_for(
|
||||
self.call_tool(self.original_name, dict(arguments or {})),
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
|
||||
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:
|
||||
@ -49,7 +52,7 @@ def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
|
||||
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()
|
||||
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
|
||||
|
||||
guidance = guard.observe_result("web_search", low_quality)
|
||||
|
||||
assert guidance is not None
|
||||
assert guidance["finish_reason"] == "web_search_low_quality_budget"
|
||||
assert "weather beijing" in guidance["message"]
|
||||
assert guard.observe_result("web_search", low_quality) is None
|
||||
|
||||
|
||||
def test_web_search_loop_guard_resets_after_useful_result() -> None:
|
||||
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": []})
|
||||
|
||||
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", low_quality) 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", 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]
|
||||
|
||||
@ -15,6 +15,7 @@ from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.engine.session.manager import SessionManager
|
||||
from beaver.services.team_service import TeamService
|
||||
from beaver.skills.assembler import SkillAssemblyResult
|
||||
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)))
|
||||
|
||||
assert result.success is False
|
||||
assert result.completion_status == "partial"
|
||||
assert result.evidence_gaps == ["unsupported evidence requirement: unknown_type"]
|
||||
assert result.success is True
|
||||
assert result.completion_status == "succeeded"
|
||||
assert result.evidence_gaps == []
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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(
|
||||
'<||DSML||tool_calls><||DSML||invoke name="web_fetch"></||DSML||invoke></||DSML||tool_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:
|
||||
_publish_skill(
|
||||
tmp_path,
|
||||
|
||||
@ -323,6 +323,14 @@ def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||
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:
|
||||
config_path = tmp_path / "config.json"
|
||||
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.display_name == "个人智能体文件系统工具"
|
||||
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",
|
||||
]
|
||||
|
||||
120
app-instance/backend/tests/unit/test_legacy_team_cleanup.py
Normal 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"
|
||||
@ -20,3 +20,30 @@ def test_local_filesystem_mcp_exposes_personal_user_file_tools_only(tmp_path) ->
|
||||
assert "search_files" not in names
|
||||
assert "list_directory" not in names
|
||||
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"]
|
||||
|
||||
@ -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")
|
||||
assert team_event["status"] == "error"
|
||||
assert team_event["text"] == "Team 执行未完成 / 子节点失败"
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
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[0]["metadata"]["timeline_type"] == "skill"
|
||||
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:
|
||||
|
||||
@ -2,38 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from beaver.skills.assembler.task_assembler import SkillAssembler
|
||||
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:
|
||||
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:
|
||||
def test_beaver_team_template_block_is_not_runtime_metadata(tmp_path) -> None:
|
||||
skill_dir = tmp_path / "plugin-skills" / "financial-comparison"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(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]
|
||||
|
||||
assert record is not None
|
||||
assert record.team_template == {
|
||||
"version": 1,
|
||||
"nodes": [{"node_id": "collect", "task": "Collect official sources"}],
|
||||
}
|
||||
assert record.team_template_warnings == []
|
||||
assert context.team_template == record.team_template
|
||||
assert context.team_template_warnings == []
|
||||
assert not hasattr(record, "team_template")
|
||||
assert not hasattr(record, "team_template_warnings")
|
||||
assert not hasattr(context, "team_template")
|
||||
assert not hasattr(context, "team_template_warnings")
|
||||
|
||||
@ -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") == []
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert evaluate_node_evidence(evidence, ["output", "unknown_type"], " ") == [
|
||||
assert evaluate_node_evidence(evidence, ["output", "至少3个价格信息来源"], " ") == [
|
||||
"missing required evidence: output",
|
||||
"unsupported evidence requirement: unknown_type",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -3,19 +3,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.tasks import SkillResolutionReport, TaskExecutionPlanner, TaskRecord
|
||||
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
|
||||
from beaver.tools.registry import ToolRegistry
|
||||
from beaver.tasks import TaskExecutionPlanner, TaskRecord
|
||||
|
||||
|
||||
class PlannerProvider(LLMProvider):
|
||||
def __init__(self, response: str) -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
self.calls = 0
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
@ -25,59 +21,18 @@ class PlannerProvider(LLMProvider):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
self.calls.append(
|
||||
{
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"model": model,
|
||||
"tools": tools,
|
||||
}
|
||||
self.calls += 1
|
||||
return LLMResponse(
|
||||
content='{"mode":"team"}',
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
return LLMResponse(content=self.response, finish_reason="stop", provider_name="stub", model="stub-model")
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
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:
|
||||
return TaskRecord(
|
||||
task_id="task-1",
|
||||
@ -93,55 +48,15 @@ def _task() -> TaskRecord:
|
||||
)
|
||||
|
||||
|
||||
def _bundle(response: str) -> ProviderBundle:
|
||||
provider = PlannerProvider(response)
|
||||
def _bundle(provider: PlannerProvider) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _bundle_with_provider(provider: LLMProvider) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
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,
|
||||
)
|
||||
def test_planner_skips_provider_for_simple_task() -> None:
|
||||
provider = PlannerProvider()
|
||||
task = _task()
|
||||
task.description = "查询深圳天气"
|
||||
task.goal = "查询深圳天气"
|
||||
@ -151,409 +66,55 @@ def test_planner_skips_llm_for_simple_task() -> None:
|
||||
task=task,
|
||||
user_message="帮我查一下今天深圳天气",
|
||||
attempt_index=1,
|
||||
provider_bundle=bundle,
|
||||
provider_bundle=_bundle(provider),
|
||||
)
|
||||
)
|
||||
|
||||
assert plan.mode == "single"
|
||||
assert plan.graph is None
|
||||
assert plan.reason == "planner_skipped_simple_task"
|
||||
assert provider.calls == []
|
||||
assert provider.calls == 0
|
||||
|
||||
|
||||
def test_planner_builds_team_graph() -> None:
|
||||
bundle = _bundle(
|
||||
"""
|
||||
{
|
||||
"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
|
||||
def test_planner_replaces_team_planning_with_workflow_tools_without_provider_call() -> None:
|
||||
provider = PlannerProvider()
|
||||
|
||||
plan = asyncio.run(
|
||||
TaskExecutionPlanner().plan(
|
||||
task=_task(),
|
||||
user_message="implement workflow",
|
||||
user_message="research and compare workflow options",
|
||||
attempt_index=1,
|
||||
provider_bundle=bundle,
|
||||
provider_bundle=_bundle(provider),
|
||||
skill_summaries=["docker-debug: Use docker logs before editing config."],
|
||||
tool_hints=["terminal", "search_files"],
|
||||
)
|
||||
)
|
||||
|
||||
assert plan.is_team
|
||||
assert plan.graph is not None
|
||||
assert plan.graph.strategy == "dag"
|
||||
assert [node.node_id for node in plan.graph.nodes] == ["research", "review"]
|
||||
assert plan.graph.nodes[1].depends_on == ["research"]
|
||||
assert plan.final_synthesis_instruction == "merge the findings"
|
||||
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
|
||||
assert not plan.is_team
|
||||
assert plan.mode == "single"
|
||||
assert plan.graph is None
|
||||
assert plan.reason == "planner_team_replaced_by_workflow_tools"
|
||||
assert plan.final_synthesis_instruction == ""
|
||||
assert provider.calls == 0
|
||||
|
||||
|
||||
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(
|
||||
TaskExecutionPlanner().plan(
|
||||
task=_task(),
|
||||
user_message="implement workflow",
|
||||
user_message="research and compare workflow options",
|
||||
attempt_index=1,
|
||||
provider_bundle=_hanging_bundle(),
|
||||
timeout_seconds=0.01,
|
||||
provider_bundle=_bundle(provider),
|
||||
)
|
||||
)
|
||||
|
||||
assert plan.mode == "single"
|
||||
assert plan.reason == "planner_failed"
|
||||
assert "TimeoutError" in (plan.fallback_error or "")
|
||||
assert plan.reason == "planner_disabled_by_environment"
|
||||
assert provider.calls == 0
|
||||
|
||||
|
||||
def test_planner_team_nodes_use_task_as_internal_skill_query() -> None:
|
||||
plan = 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
|
||||
def test_planner_no_longer_exposes_json_to_team_graph_parser() -> None:
|
||||
assert not hasattr(TaskExecutionPlanner(), "from_json")
|
||||
|
||||
@ -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
|
||||
214
app-instance/backend/tests/unit/test_team_workflow_graph.py
Normal 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",
|
||||
)
|
||||
@ -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"
|
||||
@ -146,11 +146,6 @@ export default function NotificationDetailPage() {
|
||||
isThinking={submitting}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messageViewportRef={viewportRef}
|
||||
processRuns={[]}
|
||||
processEvents={[]}
|
||||
processArtifacts={[]}
|
||||
selectedRunId={null}
|
||||
onSelectRun={() => {}}
|
||||
onFeedback={() => {}}
|
||||
onRequestRevision={() => {}}
|
||||
/>
|
||||
|
||||
@ -77,7 +77,6 @@ export default function ChatPage() {
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
setSessionId,
|
||||
setMessages,
|
||||
addMessage,
|
||||
@ -128,12 +127,6 @@ export default function ChatPage() {
|
||||
[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(
|
||||
() =>
|
||||
buildTaskTimelineView({
|
||||
@ -710,11 +703,6 @@ export default function ChatPage() {
|
||||
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messageViewportRef={messageViewportRef}
|
||||
processRuns={sessionProcessRuns}
|
||||
processEvents={sessionProcessEvents}
|
||||
processArtifacts={sessionProcessArtifacts}
|
||||
selectedRunId={selectedSessionRunId}
|
||||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||||
onFeedback={handleFeedback}
|
||||
onRequestRevision={handleRequestRevision}
|
||||
/>
|
||||
@ -881,6 +869,8 @@ export default function ChatPage() {
|
||||
|
||||
{activeTaskDetail ? (
|
||||
<CurrentSessionProgressSidebar
|
||||
task={activeTaskDetail}
|
||||
process={activeTaskTimelineView?.process ?? null}
|
||||
cards={activeTaskTimelineView?.cards ?? []}
|
||||
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
|
||||
/>
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
|
||||
import { AlertCircle, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
TaskExecutionWorkspace,
|
||||
TaskLiveHeader,
|
||||
TaskSideRail,
|
||||
TaskTimeline,
|
||||
type TaskFeedbackItem,
|
||||
type TaskFeedbackType,
|
||||
} from '@/components/task-detail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
@ -27,7 +26,6 @@ const TASK_RESULT_REVIEW_ID = 'task-result-review';
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||
const 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) {
|
||||
const feedbackItems = backendTask.feedback || [];
|
||||
|
||||
@ -139,64 +125,44 @@ export default function TaskDetailPage() {
|
||||
<div className="min-h-screen bg-background">
|
||||
<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]">
|
||||
<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>
|
||||
<main className="mx-auto min-w-0 max-w-[1720px] space-y-5 px-4 py-1 sm:px-6 lg:px-8">
|
||||
{actionError ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TaskTimeline
|
||||
cards={timelineCards}
|
||||
isLive={isTaskLive && wsStatus === 'connected'}
|
||||
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||
resultAcceptance={{
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
taskStatus: backendTask.status,
|
||||
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange: setRevision,
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||
await submitChatFeedback({
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||
setRevision('');
|
||||
await loadBackendTask();
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskSideRail
|
||||
<TaskExecutionWorkspace
|
||||
task={backendTask}
|
||||
runs={timelineView?.process.runs ?? []}
|
||||
artifacts={timelineView?.process.artifacts ?? []}
|
||||
process={timelineView?.process ?? { runs: [], events: [], artifacts: [] }}
|
||||
cards={timelineCards}
|
||||
isLive={isTaskLive && wsStatus === 'connected'}
|
||||
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||
resultAcceptance={{
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
taskStatus: backendTask.status,
|
||||
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange: setRevision,
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||
await submitChatFeedback({
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||
setRevision('');
|
||||
await loadBackendTask();
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -10,7 +10,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<main className="pt-14">
|
||||
<AuthGuard>
|
||||
<AppRuntimeBridge />
|
||||
{children}
|
||||
|
||||
@ -131,8 +131,8 @@ const Header = () => {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
|
||||
className={`flex h-11 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'
|
||||
className={`flex shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
|
||||
compact ? 'h-11 justify-start rounded-lg border border-transparent bg-background px-4' : 'h-10 px-3.5'
|
||||
} ${
|
||||
isActive
|
||||
? '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">
|
||||
<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">
|
||||
<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-expanded={mobileMenuOpen}
|
||||
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" />}
|
||||
</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]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
</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)}
|
||||
</nav>
|
||||
|
||||
@ -245,14 +248,14 @@ const Header = () => {
|
||||
<>
|
||||
<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')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<nav
|
||||
id="app-primary-mobile-nav"
|
||||
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="grid gap-2 bg-background">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import type { ChatMessage } from '@/types';
|
||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||
|
||||
export function ChatWorkbench({
|
||||
@ -10,11 +10,6 @@ export function ChatWorkbench({
|
||||
isThinking,
|
||||
messagesEndRef,
|
||||
messageViewportRef,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onFeedback,
|
||||
onRequestRevision,
|
||||
}: {
|
||||
@ -22,11 +17,6 @@ export function ChatWorkbench({
|
||||
isThinking: boolean;
|
||||
messagesEndRef: 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;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
@ -37,11 +27,6 @@ export function ChatWorkbench({
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
|
||||
@ -1,24 +1,245 @@
|
||||
'use client';
|
||||
|
||||
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 { formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
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({
|
||||
task,
|
||||
process,
|
||||
cards,
|
||||
isLive,
|
||||
onClose,
|
||||
}: {
|
||||
task: BackendTask | null;
|
||||
process: SessionProcessProjection | null;
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const model = task
|
||||
? buildTaskUiModel({
|
||||
task,
|
||||
process: process ?? { runs: [], events: [], artifacts: [] },
|
||||
cards,
|
||||
locale,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-[#FBFAF9]">
|
||||
@ -48,9 +269,58 @@ function ProgressPanel({
|
||||
) : null}
|
||||
</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">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@ -58,9 +328,13 @@ function ProgressPanel({
|
||||
}
|
||||
|
||||
export function CurrentSessionProgressSidebar({
|
||||
task,
|
||||
process,
|
||||
cards,
|
||||
isLive,
|
||||
}: {
|
||||
task: BackendTask | null;
|
||||
process: SessionProcessProjection | null;
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
}) {
|
||||
@ -70,7 +344,7 @@ export function CurrentSessionProgressSidebar({
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<button
|
||||
@ -91,7 +365,7 @@ export function CurrentSessionProgressSidebar({
|
||||
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">
|
||||
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
|
||||
<ProgressPanel task={task} process={process} cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -4,10 +4,9 @@ import React from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
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 {
|
||||
if (message.role !== 'assistant' || typeof message.content !== 'string') {
|
||||
return false;
|
||||
@ -299,72 +290,11 @@ function shouldHideMessage(message: ChatMessage): boolean {
|
||||
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({
|
||||
messages,
|
||||
isThinking,
|
||||
messagesEndRef,
|
||||
viewportRef,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onFeedback,
|
||||
onRequestRevision,
|
||||
}: {
|
||||
@ -372,11 +302,6 @@ export function MessageList({
|
||||
isThinking: boolean;
|
||||
messagesEndRef: 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;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
@ -385,37 +310,6 @@ export function MessageList({
|
||||
() => messages.filter((message) => !shouldHideMessage(message)),
|
||||
[messages]
|
||||
);
|
||||
const teamGroups = React.useMemo(
|
||||
() =>
|
||||
buildAgentTeamGroups(processRuns).filter((group) =>
|
||||
group.memberRuns.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))
|
||||
),
|
||||
[processRuns]
|
||||
);
|
||||
const timelineItems = React.useMemo(() => {
|
||||
const messageItems = 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(
|
||||
() => getTaskCardMessageIndexes(visibleMessages),
|
||||
[visibleMessages]
|
||||
@ -439,7 +333,7 @@ export function MessageList({
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium text-foreground">Beaver</p>
|
||||
@ -447,28 +341,16 @@ export function MessageList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timelineItems.map((item) =>
|
||||
item.kind === 'message' ? (
|
||||
<MessageBubble
|
||||
key={item.key}
|
||||
message={item.message}
|
||||
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
||||
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
) : (
|
||||
<AgentTeamBlock
|
||||
key={item.key}
|
||||
rootRun={item.group.rootRun}
|
||||
memberRuns={item.group.memberRuns}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{visibleMessages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={`${message.role}:${message.timestamp || index}:${index}`}
|
||||
message={message}
|
||||
showTaskCard={taskCardMessageIndexes.has(index)}
|
||||
canSendFeedback={index === latestFeedbackMessageIndex}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
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">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<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-[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 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">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</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="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{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}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{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="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 ? (
|
||||
<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}
|
||||
</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>
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
export { TaskExecutionWorkspace } from './TaskExecutionWorkspace';
|
||||
export { TaskLiveHeader } from './TaskLiveHeader';
|
||||
export { TaskSideRail } from './TaskSideRail';
|
||||
export { TaskTimeline } from './TaskTimeline';
|
||||
|
||||
@ -131,7 +131,7 @@ function titleForCard(type: TaskTimelineCardType, actorName?: string, locale: Ap
|
||||
case 'next_step':
|
||||
return pickAppText(locale, '下一步', 'Next step');
|
||||
case 'agent_team':
|
||||
return pickAppText(locale, '启动 Agent Team', 'Agent team started');
|
||||
return pickAppText(locale, 'Agent Team 执行', 'Agent Team execution');
|
||||
case 'agent_progress':
|
||||
return actorName || pickAppText(locale, 'Agent 进展', 'Agent progress');
|
||||
case 'agent_handoff':
|
||||
|
||||
304
app-instance/frontend/lib/task-ui-model.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
692
app-instance/frontend/lib/task-ui-model.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
1624
docs/prd/Beaver_Product_Re-Discovery_PRD_Template_v0.1 (1).md
Normal file
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 899 KiB |
@ -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}
|
||||
}
|
||||
150
docs/presentations/beaver-product-sale/assets/base.css
Normal 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}
|
||||
}
|
||||
9
docs/presentations/beaver-product-sale/assets/fonts.css
Normal 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');
|
||||
960
docs/presentations/beaver-product-sale/assets/runtime.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
@ -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}
|
||||
@ -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;
|
||||
}
|
||||
@ -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%)}
|
||||
@ -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}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
@ -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}}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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%}
|
||||
@ -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)}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)}
|
||||