diff --git a/agents/registry.json b/agents/registry.json new file mode 100644 index 0000000..6ababd4 --- /dev/null +++ b/agents/registry.json @@ -0,0 +1,145 @@ +{ + "agents": [ + { + "agent_id": "researcher", + "capabilities": [ + "research", + "analysis", + "source review", + "requirements" + ], + "created_at": "2026-05-11T03:13:06.912240+00:00", + "description": "Finds facts, references, constraints, and implementation options.", + "display_name": "Researcher", + "metadata": {}, + "model": null, + "name": "researcher", + "priority": 50, + "provider_name": null, + "role": "research", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.", + "tags": [ + "planning", + "research" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.912247+00:00" + }, + { + "agent_id": "implementer", + "capabilities": [ + "implementation", + "coding", + "refactor", + "integration" + ], + "created_at": "2026-05-11T03:13:06.912250+00:00", + "description": "Builds scoped implementation slices and proposes concrete changes.", + "display_name": "Implementer", + "metadata": {}, + "model": null, + "name": "implementer", + "priority": 45, + "provider_name": null, + "role": "implementation", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.", + "tags": [ + "coding", + "build" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.912251+00:00" + }, + { + "agent_id": "reviewer", + "capabilities": [ + "review", + "quality", + "risk", + "verification" + ], + "created_at": "2026-05-11T03:13:06.912252+00:00", + "description": "Reviews plans, code, outputs, and risks before final synthesis.", + "display_name": "Reviewer", + "metadata": {}, + "model": null, + "name": "reviewer", + "priority": 45, + "provider_name": null, + "role": "review", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.", + "tags": [ + "review", + "quality" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.912253+00:00" + }, + { + "agent_id": "tester", + "capabilities": [ + "testing", + "verification", + "regression", + "qa" + ], + "created_at": "2026-05-11T03:13:06.912255+00:00", + "description": "Designs and executes verification checks for task outputs.", + "display_name": "Tester", + "metadata": {}, + "model": null, + "name": "tester", + "priority": 40, + "provider_name": null, + "role": "testing", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.", + "tags": [ + "test", + "quality" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.912256+00:00" + }, + { + "agent_id": "documenter", + "capabilities": [ + "documentation", + "explanation", + "migration notes", + "release notes" + ], + "created_at": "2026-05-11T03:13:06.912257+00:00", + "description": "Writes and reconciles user-facing and internal documentation updates.", + "display_name": "Documenter", + "metadata": {}, + "model": null, + "name": "documenter", + "priority": 35, + "provider_name": null, + "role": "documentation", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.", + "tags": [ + "docs", + "communication" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.912258+00:00" + } + ], + "version": 1 +} diff --git a/app-instance/agents/registry.json b/app-instance/agents/registry.json new file mode 100644 index 0000000..1f7b731 --- /dev/null +++ b/app-instance/agents/registry.json @@ -0,0 +1,145 @@ +{ + "agents": [ + { + "agent_id": "researcher", + "capabilities": [ + "research", + "analysis", + "source review", + "requirements" + ], + "created_at": "2026-05-11T03:13:06.921512+00:00", + "description": "Finds facts, references, constraints, and implementation options.", + "display_name": "Researcher", + "metadata": {}, + "model": null, + "name": "researcher", + "priority": 50, + "provider_name": null, + "role": "research", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.", + "tags": [ + "planning", + "research" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.921520+00:00" + }, + { + "agent_id": "implementer", + "capabilities": [ + "implementation", + "coding", + "refactor", + "integration" + ], + "created_at": "2026-05-11T03:13:06.921522+00:00", + "description": "Builds scoped implementation slices and proposes concrete changes.", + "display_name": "Implementer", + "metadata": {}, + "model": null, + "name": "implementer", + "priority": 45, + "provider_name": null, + "role": "implementation", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.", + "tags": [ + "coding", + "build" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.921523+00:00" + }, + { + "agent_id": "reviewer", + "capabilities": [ + "review", + "quality", + "risk", + "verification" + ], + "created_at": "2026-05-11T03:13:06.921527+00:00", + "description": "Reviews plans, code, outputs, and risks before final synthesis.", + "display_name": "Reviewer", + "metadata": {}, + "model": null, + "name": "reviewer", + "priority": 45, + "provider_name": null, + "role": "review", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.", + "tags": [ + "review", + "quality" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.921528+00:00" + }, + { + "agent_id": "tester", + "capabilities": [ + "testing", + "verification", + "regression", + "qa" + ], + "created_at": "2026-05-11T03:13:06.921529+00:00", + "description": "Designs and executes verification checks for task outputs.", + "display_name": "Tester", + "metadata": {}, + "model": null, + "name": "tester", + "priority": 40, + "provider_name": null, + "role": "testing", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.", + "tags": [ + "test", + "quality" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.921530+00:00" + }, + { + "agent_id": "documenter", + "capabilities": [ + "documentation", + "explanation", + "migration notes", + "release notes" + ], + "created_at": "2026-05-11T03:13:06.921533+00:00", + "description": "Writes and reconciles user-facing and internal documentation updates.", + "display_name": "Documenter", + "metadata": {}, + "model": null, + "name": "documenter", + "priority": 35, + "provider_name": null, + "role": "documentation", + "skill_names": [], + "source": "builtin", + "status": "active", + "system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.", + "tags": [ + "docs", + "communication" + ], + "tool_hints": [], + "updated_at": "2026-05-11T03:13:06.921534+00:00" + } + ], + "version": 1 +} diff --git a/app-instance/backend/README.md b/app-instance/backend/README.md index 8a34d91..8746639 100644 --- a/app-instance/backend/README.md +++ b/app-instance/backend/README.md @@ -10,7 +10,7 @@ 2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。 3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。 4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。 -5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/team;team node 由 `TaskSkillResolver` 绑定 published skill,缺失时生成 draft-only ephemeral skill,最终仍由主 Agent synthesis 生成用户回答。 +5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/team;team node 由 `TaskSkillResolver` 绑定 published skill,缺失时生成 ephemeral guidance,最终仍由主 Agent synthesis 生成用户回答。 6. Skill Learning 已支持后台 run-once/worker 自动生成 draft、safety report、eval report、人工审核发布和前端审核工作台;worker 不会自动 approve/publish。 ## 当前结构 diff --git a/app-instance/backend/beaver/coordinator/execution/scheduler.py b/app-instance/backend/beaver/coordinator/execution/scheduler.py index f8c4137..234407a 100644 --- a/app-instance/backend/beaver/coordinator/execution/scheduler.py +++ b/app-instance/backend/beaver/coordinator/execution/scheduler.py @@ -32,7 +32,7 @@ class TeamGraphScheduler: provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None, inherited_pinned_skills: list[str] | None = None, inherited_pinned_skill_contexts: list["SkillContext"] | None = None, - learning_candidate_enabled: bool = False, + allow_candidate_generation: bool = False, ) -> TeamRunResult: graph.validate() if provider_bundle is not None and len(graph.nodes) > 1: @@ -49,7 +49,7 @@ class TeamGraphScheduler: provider_bundle_factory=provider_bundle_factory, inherited_pinned_skills=inherited, inherited_pinned_skill_contexts=inherited_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) elif graph.strategy == "parallel": results = await self._run_parallel( @@ -61,7 +61,7 @@ class TeamGraphScheduler: provider_bundle_factory=provider_bundle_factory, inherited_pinned_skills=inherited, inherited_pinned_skill_contexts=inherited_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) else: results = await self._run_dag( @@ -73,7 +73,7 @@ class TeamGraphScheduler: provider_bundle_factory=provider_bundle_factory, inherited_pinned_skills=inherited, inherited_pinned_skill_contexts=inherited_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) return self._summarize(results, task_id=parent_task_id) @@ -162,7 +162,7 @@ class TeamGraphScheduler: provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None, inherited_pinned_skills: list[str], inherited_pinned_skill_contexts: list["SkillContext"], - learning_candidate_enabled: bool, + allow_candidate_generation: bool, dependency_outputs: dict[str, str], ) -> NodeRunResult: try: @@ -188,7 +188,7 @@ class TeamGraphScheduler: return await self.runner.run( envelope, provider_bundle=node_provider_bundle, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) except asyncio.CancelledError: raise diff --git a/app-instance/backend/beaver/coordinator/local.py b/app-instance/backend/beaver/coordinator/local.py index da46d1c..df448bb 100644 --- a/app-instance/backend/beaver/coordinator/local.py +++ b/app-instance/backend/beaver/coordinator/local.py @@ -21,7 +21,7 @@ class LocalAgentRunner: envelope: DelegationEnvelope, *, provider_bundle: ProviderBundle | None = None, - learning_candidate_enabled: bool = False, + allow_candidate_generation: bool = False, ) -> NodeRunResult: if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name): raise ValueError( @@ -37,6 +37,7 @@ class LocalAgentRunner: source=f"team:{envelope.agent.name}", title=envelope.agent.role or envelope.agent.name, execution_context=self._execution_context(envelope), + skill_selection_context=self._skill_selection_context(envelope), model=envelope.agent.model, provider_name=envelope.agent.provider_name, provider_bundle=provider_bundle, @@ -44,7 +45,7 @@ class LocalAgentRunner: task_mode=bool(envelope.parent_task_id), pinned_skill_names=envelope.inherited_pinned_skills, pinned_skill_contexts=envelope.inherited_pinned_skill_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) success = result.finish_reason == "stop" return NodeRunResult( @@ -86,7 +87,48 @@ class LocalAgentRunner: sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills)) if envelope.inherited_pinned_skill_contexts: sections.append( - "Ephemeral pinned skill drafts:\n" + "Ephemeral pinned guidance:\n" + "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts) ) return "\n\n".join(sections) + + @staticmethod + def _skill_selection_context(envelope: DelegationEnvelope) -> str: + sections: list[str] = [] + if envelope.parent_task_id: + sections.append(f"Parent task ID:\n{envelope.parent_task_id}") + sections.append(f"Node task:\n{envelope.task}") + sections.append("Execution phase:\nteam_node") + if envelope.agent.role: + sections.append(f"Agent role:\n{envelope.agent.role}") + skill_query = envelope.agent.metadata.get("skill_query") + if skill_query: + sections.append(f"Skill query:\n{skill_query}") + required_capabilities = envelope.agent.metadata.get("required_capabilities") + if required_capabilities: + if isinstance(required_capabilities, list): + rendered = "\n".join(f"- {item}" for item in required_capabilities) + else: + rendered = str(required_capabilities) + sections.append(f"Required capabilities:\n{rendered}") + if envelope.constraints: + sections.append("Constraints:\n" + "\n".join(f"- {item}" for item in envelope.constraints)) + if envelope.expected_output: + sections.append(f"Expected output:\n{envelope.expected_output}") + if envelope.inherited_pinned_skills: + sections.append( + "Pinned inherited skills (must be injected separately; use as strong context):\n" + + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills) + ) + if envelope.dependency_outputs: + rendered = "\n\n".join( + f"Dependency {node_id} output:\n{output[:800]}" + for node_id, output in envelope.dependency_outputs.items() + ) + sections.append("Dependency outputs:\n" + rendered) + sections.append( + "Skill selection instruction:\n" + "Select published skills for this delegated node. " + "If no published skill matches, return [] and let the node continue without skills." + ) + return "\n\n".join(sections) diff --git a/app-instance/backend/beaver/engine/context/builder.py b/app-instance/backend/beaver/engine/context/builder.py index 6ed1e97..c66e351 100644 --- a/app-instance/backend/beaver/engine/context/builder.py +++ b/app-instance/backend/beaver/engine/context/builder.py @@ -22,6 +22,7 @@ from __future__ import annotations +import json from dataclasses import dataclass, field from typing import Any @@ -224,8 +225,29 @@ class ContextBuilder: clean = {key: value for key, value in message.items() if key in allowed} if "name" not in clean and message.get("tool_name"): clean["name"] = message.get("tool_name") + if isinstance(clean.get("tool_calls"), list): + clean["tool_calls"] = ContextBuilder._provider_tool_calls(clean["tool_calls"]) return clean + @staticmethod + def _provider_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize persisted tool calls to OpenAI-compatible provider payloads.""" + + normalized: list[dict[str, Any]] = [] + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + clean = dict(tool_call) + function = clean.get("function") + if isinstance(function, dict): + clean_function = dict(function) + arguments = clean_function.get("arguments") + if not isinstance(arguments, str): + clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str) + clean["function"] = clean_function + normalized.append(clean) + return normalized + def add_tool_result( self, messages: list[dict[str, Any]], @@ -278,7 +300,7 @@ class ContextBuilder: "content": content, } if tool_calls: - message["tool_calls"] = tool_calls + message["tool_calls"] = self._provider_tool_calls(tool_calls) if reasoning_content is not None: message["reasoning_content"] = reasoning_content messages.append(message) diff --git a/app-instance/backend/beaver/engine/loader.py b/app-instance/backend/beaver/engine/loader.py index d80c125..35ff333 100644 --- a/app-instance/backend/beaver/engine/loader.py +++ b/app-instance/backend/beaver/engine/loader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os from dataclasses import dataclass, field from pathlib import Path @@ -11,6 +12,7 @@ from beaver.coordinator.registry import AgentRegistry from beaver.engine.context import ContextBuilder from beaver.engine.session import SessionManager from beaver.foundation.config import BeaverConfig, load_config +from beaver.integrations.mcp import MCPConnectionManager from beaver.memory.curated.store import MemoryStore from beaver.memory.runs import RunMemoryStore from beaver.memory.skills import SkillLearningStore @@ -27,13 +29,27 @@ from beaver.tasks.skill_resolver import TaskSkillResolver from beaver.skills import SkillAssembler, SkillsLoader from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry from beaver.tools.builtins import ( + ClarifyTool, + CronTool, + DelegateTool, EchoTool, + ExecuteCodeTool, ListDirectoryTool, MemoryTool, + PatchFileTool, + ProcessTool, ReadFileTool, SearchFilesTool, + SendMessageTool, + SpawnTool, SessionSearchTool, - SkillViewTool, + SkillManageTool, + SkillsListTool, + TerminalTool, + TodoTool, + WebFetchTool, + WebSearchTool, + WriteFileTool, ) @@ -76,6 +92,8 @@ class EngineLoadResult: task_service: TaskService | None = None task_execution_planner: TaskExecutionPlanner | None = None validation_service: ValidationService | None = None + mcp_manager: MCPConnectionManager | None = None + mcp_report: dict[str, dict] = field(default_factory=dict) closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False) closed: bool = False @@ -198,11 +216,25 @@ class EngineLoader: [ ObjectBackedTool(EchoTool()), ObjectBackedTool(MemoryTool(store=memory_service.get_store())), - ObjectBackedTool(SkillViewTool(loader=skills_loader)), ObjectBackedTool(SessionSearchTool(db=session_manager)), ObjectBackedTool(ListDirectoryTool()), ObjectBackedTool(ReadFileTool()), ObjectBackedTool(SearchFilesTool()), + ObjectBackedTool(WriteFileTool()), + ObjectBackedTool(PatchFileTool()), + ObjectBackedTool(WebFetchTool()), + ObjectBackedTool(WebSearchTool()), + ObjectBackedTool(TerminalTool()), + ObjectBackedTool(ProcessTool()), + ObjectBackedTool(ExecuteCodeTool()), + ObjectBackedTool(TodoTool()), + ObjectBackedTool(ClarifyTool()), + ObjectBackedTool(SendMessageTool()), + ObjectBackedTool(DelegateTool()), + ObjectBackedTool(SpawnTool()), + SkillsListTool(), + SkillManageTool(), + CronTool(), ] ) @@ -240,6 +272,11 @@ class EngineLoader: task_service = self._task_service or TaskService(workspace / "tasks") task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver) validation_service = self._validation_service or ValidationService() + mcp_manager = MCPConnectionManager( + self.config.tools.mcp_servers, + authz_config=self.config.authz, + backend_identity=self.config.backend_identity, + ) result = EngineLoadResult( workspace=workspace, @@ -270,7 +307,18 @@ class EngineLoader: task_service=task_service, task_execution_planner=task_execution_planner, validation_service=validation_service, + mcp_manager=mcp_manager, ) if self._session_manager is None: result.register_closeable("session_manager", session_manager.close) + result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager)) return result + + +def _close_mcp_manager(manager: MCPConnectionManager) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(manager.close()) + return + loop.create_task(manager.close()) diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py index fd2586d..1940f90 100644 --- a/app-instance/backend/beaver/engine/loop.py +++ b/app-instance/backend/beaver/engine/loop.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any @@ -64,6 +65,7 @@ class AgentLoop: self.profile = profile or AgentProfile() self.loader = loader or EngineLoader() self.loaded: EngineLoadResult | None = None + self.runtime_services: dict[str, Any] = {} self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None self._running = False self._stop_requested = False @@ -190,6 +192,7 @@ class AgentLoop: user_id: str | None = None, title: str | None = None, execution_context: str | None = None, + skill_selection_context: str | None = None, model: str | None = None, provider_name: str | None = None, api_key: str | None = None, @@ -202,6 +205,9 @@ class AgentLoop: embedding_model: str | None = None, max_tokens: int | None = None, temperature: float | None = None, + thinking_enabled: bool | None = None, + include_skill_assembly: bool = True, + include_tools: bool = True, max_tool_iterations: int | None = None, provider_bundle: ProviderBundle | None = None, parent_session_id: str | None = None, @@ -210,7 +216,7 @@ class AgentLoop: attempt_index: int | None = None, pinned_skill_names: list[str] | None = None, pinned_skill_contexts: list[SkillContext] | None = None, - learning_candidate_enabled: bool = False, + allow_candidate_generation: bool = False, ) -> AgentRunResult: """跑通最小 direct run 主链。 @@ -234,6 +240,7 @@ class AgentLoop: user_id=user_id, title=title, execution_context=execution_context, + skill_selection_context=skill_selection_context, model=model, provider_name=provider_name, api_key=api_key, @@ -246,6 +253,9 @@ class AgentLoop: embedding_model=embedding_model, max_tokens=max_tokens, temperature=temperature, + thinking_enabled=thinking_enabled, + include_skill_assembly=include_skill_assembly, + include_tools=include_tools, max_tool_iterations=max_tool_iterations, provider_bundle=provider_bundle, parent_session_id=parent_session_id, @@ -254,7 +264,7 @@ class AgentLoop: attempt_index=attempt_index, pinned_skill_names=pinned_skill_names, pinned_skill_contexts=pinned_skill_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) async def _process_direct_impl( @@ -266,6 +276,7 @@ class AgentLoop: user_id: str | None = None, title: str | None = None, execution_context: str | None = None, + skill_selection_context: str | None = None, model: str | None = None, provider_name: str | None = None, api_key: str | None = None, @@ -278,6 +289,9 @@ class AgentLoop: embedding_model: str | None = None, max_tokens: int | None = None, temperature: float | None = None, + thinking_enabled: bool | None = None, + include_skill_assembly: bool = True, + include_tools: bool = True, max_tool_iterations: int | None = None, provider_bundle: ProviderBundle | None = None, parent_session_id: str | None = None, @@ -286,7 +300,7 @@ class AgentLoop: attempt_index: int | None = None, pinned_skill_names: list[str] | None = None, pinned_skill_contexts: list[SkillContext] | None = None, - learning_candidate_enabled: bool = False, + allow_candidate_generation: bool = False, ) -> AgentRunResult: """真正执行一轮 direct run 的内部实现。 @@ -306,6 +320,10 @@ class AgentLoop: skills_loader = self._require_loaded("skills_loader") skill_assembler = self._require_loaded("skill_assembler") skill_learning_service = self._require_loaded("skill_learning_service") + mcp_manager = getattr(loaded, "mcp_manager", None) + if mcp_manager is not None: + loaded.mcp_report = await mcp_manager.connect_all(tool_registry) + loaded.tools = [spec.name for spec in tool_registry.list_specs()] config = loaded.config configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name) @@ -357,6 +375,9 @@ class AgentLoop: "task_id": task_id, "task_mode": task_mode, "attempt_index": attempt_index, + "thinking_enabled": thinking_enabled, + "include_skill_assembly": include_skill_assembly, + "skill_selection_context_present": bool(skill_selection_context), "parent_session_id": parent_session_id, "pinned_skill_names": list(pinned_skill_names or []), "pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []], @@ -396,19 +417,39 @@ class AgentLoop: if bundle.auxiliary_runtime is not None else bundle.main_runtime.model ) - assembled_skills = await skill_assembler.assemble( - task_description=task, - provider=skill_selector_provider, - model=skill_selector_model, - embedding_runtime=bundle.embedding_runtime, - ) - activated_skills = self._merge_skill_contexts( - [ - *(pinned_skill_contexts or []), - *self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []), - ], - assembled_skills.activated_skills, - ) + pinned_skills = [ + *(pinned_skill_contexts or []), + *self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []), + ] + if not include_skill_assembly or thinking_enabled is False: + activated_skills = self._merge_skill_contexts(pinned_skills, []) + else: + skill_query = skill_selection_context or task + assembled_skills = await skill_assembler.assemble( + task_description=skill_query, + provider=skill_selector_provider, + model=skill_selector_model, + embedding_runtime=bundle.embedding_runtime, + thinking_enabled=thinking_enabled, + ) + for interaction in getattr(assembled_skills, "llm_interactions", []) or []: + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="skill_assembler_llm_interaction_snapshotted", + event_payload=interaction, + content=json.dumps(interaction, ensure_ascii=False, default=str), + context_visible=False, + source=source, + title=title, + model=skill_selector_model, + user_id=user_id, + ) + activated_skills = self._merge_skill_contexts( + pinned_skills, + assembled_skills.activated_skills, + ) skill_activation_messages = context_builder.build_skill_activation_messages( activated_skills ) @@ -444,14 +485,19 @@ class AgentLoop: user_id=user_id, ) - selected_tool_specs = await tool_assembler.assemble( - task_description=task, - registry=tool_registry, - skills_loader=skills_loader, - activated_skills=activated_skills, - embedding_runtime=bundle.embedding_runtime, - top_k=10, - ) + if not include_tools: + selected_tool_specs = [] + elif thinking_enabled is False: + selected_tool_specs = tool_registry.list_specs() + else: + selected_tool_specs = await tool_assembler.assemble( + task_description=task, + registry=tool_registry, + skills_loader=skills_loader, + activated_skills=activated_skills, + embedding_runtime=bundle.embedding_runtime, + top_k=10, + ) tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs) session_manager.append_message( resolved_session_id, @@ -486,6 +532,25 @@ class AgentLoop: execution_context=execution_context, ) context_result = context_builder.build_messages(build_input) + if skill_selection_context: + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="skill_selection_context_snapshotted", + event_payload={ + "skill_selection_context": skill_selection_context, + "task_id": task_id, + "task_mode": task_mode, + "attempt_index": attempt_index, + }, + content=skill_selection_context, + context_visible=False, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt) session_manager.append_message( resolved_session_id, @@ -528,6 +593,9 @@ class AgentLoop: "memory_service": memory_service, "memory_store": memory_service.get_store(), "tool_registry": tool_registry, + "skills_loader": skills_loader, + "draft_service": getattr(loaded, "draft_service", None), + **self.runtime_services, }, metadata={ "source": source, @@ -541,13 +609,45 @@ class AgentLoop: final_model = bundle.main_runtime.model while True: - response = await provider.chat( - messages=messages, - tools=tool_schemas, + chat_kwargs: dict[str, Any] = { + "messages": messages, + "tools": tool_schemas, + "model": final_model, + "max_tokens": resolved_max_tokens, + "temperature": resolved_temperature, + } + if thinking_enabled is not None: + chat_kwargs["thinking_enabled"] = thinking_enabled + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="llm_request_snapshotted", + event_payload={ + "iteration": iterations, + "provider_name": final_provider_name, + "model": final_model, + "messages": messages, + "tools": tool_schemas, + "max_tokens": resolved_max_tokens, + "temperature": resolved_temperature, + "thinking_enabled": thinking_enabled, + }, + content=json.dumps( + { + "messages": messages, + "tools": tool_schemas, + }, + ensure_ascii=False, + default=str, + ), + context_visible=False, + source=source, + title=title, model=final_model, - max_tokens=resolved_max_tokens, - temperature=resolved_temperature, + user_id=user_id, ) + response = await provider.chat(**chat_kwargs) final_provider_name = response.provider_name or final_provider_name final_model = response.model or final_model final_usage = self._merge_usage(final_usage, response.usage or {}) @@ -650,7 +750,7 @@ class AgentLoop: model=final_model, user_id=user_id, ) - self._record_skill_learning( + self._record_run_receipts( skill_learning_service=skill_learning_service, session_manager=session_manager, session_id=resolved_session_id, @@ -663,7 +763,7 @@ class AgentLoop: success=(final_finish_reason == "stop"), task_id=task_id, attempt_index=attempt_index, - generate_candidates=learning_candidate_enabled, + allow_candidate_generation=False, ) return AgentRunResult( session_id=resolved_session_id, @@ -703,7 +803,7 @@ class AgentLoop: usage=final_usage, task_id=task_id, ) - self._record_skill_learning( + self._record_run_receipts( skill_learning_service=skill_learning_service, session_manager=session_manager, session_id=resolved_session_id, @@ -716,7 +816,7 @@ class AgentLoop: success=False, task_id=task_id, attempt_index=attempt_index, - generate_candidates=learning_candidate_enabled, + allow_candidate_generation=False, ) return result @@ -771,13 +871,16 @@ class AgentLoop: def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]: payload: list[dict[str, Any]] = [] for tool_call in tool_calls: + arguments = tool_call.arguments + if not isinstance(arguments, str): + arguments = json.dumps(arguments or {}, ensure_ascii=False, default=str) payload.append( { "id": tool_call.id, "type": "function", "function": { "name": tool_call.name, - "arguments": tool_call.arguments, + "arguments": arguments, }, } ) @@ -877,7 +980,7 @@ class AgentLoop: ) @staticmethod - def _record_skill_learning( + def _record_run_receipts( *, skill_learning_service: Any, session_manager: Any, @@ -891,7 +994,7 @@ class AgentLoop: success: bool, task_id: str | None = None, attempt_index: int | None = None, - generate_candidates: bool = False, + allow_candidate_generation: bool = False, ) -> None: run_record = RunRecord( run_id=run_id, @@ -921,7 +1024,7 @@ class AgentLoop: try: candidates = skill_learning_service.collect_run_receipts( RunReceiptContext(run_record=run_record, effect_records=effect_records), - generate_candidates=generate_candidates, + generate_candidates=allow_candidate_generation, ) except Exception as exc: # pragma: no cover - defensive hot-path guard session_manager.append_message( @@ -948,7 +1051,7 @@ class AgentLoop: "run_record": run_record.to_dict(), "skill_effects": [item.to_dict() for item in effect_records], "learning_candidates": [candidate.to_dict() for candidate in candidates], - "learning_candidate_enabled": generate_candidates, + "candidate_generation_allowed": allow_candidate_generation, }, content=f"Recorded {len(effect_records)} skill effect record(s).", context_visible=False, diff --git a/app-instance/backend/beaver/engine/providers/anthropic.py b/app-instance/backend/beaver/engine/providers/anthropic.py index 60a66c9..a7a9a65 100644 --- a/app-instance/backend/beaver/engine/providers/anthropic.py +++ b/app-instance/backend/beaver/engine/providers/anthropic.py @@ -45,6 +45,7 @@ class AnthropicProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: try: client = self._client_or_raise() diff --git a/app-instance/backend/beaver/engine/providers/base.py b/app-instance/backend/beaver/engine/providers/base.py index 98756d8..10dcb65 100644 --- a/app-instance/backend/beaver/engine/providers/base.py +++ b/app-instance/backend/beaver/engine/providers/base.py @@ -90,6 +90,7 @@ class LLMProvider(ABC): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: """统一聊天接口。""" diff --git a/app-instance/backend/beaver/engine/providers/chain.py b/app-instance/backend/beaver/engine/providers/chain.py index 7dc60ab..0830f1d 100644 --- a/app-instance/backend/beaver/engine/providers/chain.py +++ b/app-instance/backend/beaver/engine/providers/chain.py @@ -58,6 +58,7 @@ class FallbackProviderChain(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: self._last_provider = self.primary_provider self._last_runtime = self.primary_runtime @@ -71,6 +72,7 @@ class FallbackProviderChain(LLMProvider): model=model or self.primary_runtime.model, max_tokens=max_tokens, temperature=temperature, + thinking_enabled=thinking_enabled, ) response = self._decorate_response(response, self.primary_runtime) if not self._should_activate_fallback(response): @@ -91,6 +93,7 @@ class FallbackProviderChain(LLMProvider): model=self.fallback_runtime.model, max_tokens=max_tokens, temperature=temperature, + thinking_enabled=thinking_enabled, ) return self._decorate_response(response, self.fallback_runtime) @@ -114,6 +117,7 @@ class FallbackProviderChain(LLMProvider): model: str, max_tokens: int, temperature: float, + thinking_enabled: bool | None, ) -> LLMResponse: """把 provider 抛出的异常也收敛成统一 error response。 @@ -121,13 +125,16 @@ class FallbackProviderChain(LLMProvider): """ try: - return await provider.chat( - messages=messages, - tools=tools, - model=model, - max_tokens=max_tokens, - temperature=temperature, - ) + kwargs = { + "messages": messages, + "tools": tools, + "model": model, + "max_tokens": max_tokens, + "temperature": temperature, + } + if thinking_enabled is not None: + kwargs["thinking_enabled"] = thinking_enabled + return await provider.chat(**kwargs) except Exception as exc: return LLMResponse( content=f"Error: {exc}", diff --git a/app-instance/backend/beaver/engine/providers/codex.py b/app-instance/backend/beaver/engine/providers/codex.py index 49ba00f..7d773ad 100644 --- a/app-instance/backend/beaver/engine/providers/codex.py +++ b/app-instance/backend/beaver/engine/providers/codex.py @@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: if httpx is None or get_codex_token is None: return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex") diff --git a/app-instance/backend/beaver/engine/providers/custom.py b/app-instance/backend/beaver/engine/providers/custom.py index 3d1fee2..9222feb 100644 --- a/app-instance/backend/beaver/engine/providers/custom.py +++ b/app-instance/backend/beaver/engine/providers/custom.py @@ -49,6 +49,7 @@ class CustomProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: client = self._client_or_raise() kwargs: dict[str, Any] = { diff --git a/app-instance/backend/beaver/engine/providers/litellm.py b/app-instance/backend/beaver/engine/providers/litellm.py index 24b2bb7..8b191f0 100644 --- a/app-instance/backend/beaver/engine/providers/litellm.py +++ b/app-instance/backend/beaver/engine/providers/litellm.py @@ -123,6 +123,25 @@ class LiteLLMProvider(LLMProvider): clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS} if clean.get("role") == "assistant" and "content" not in clean: clean["content"] = None + if isinstance(clean.get("tool_calls"), list): + clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"]) + sanitized.append(clean) + return sanitized + + @staticmethod + def _sanitize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]: + sanitized: list[dict[str, Any]] = [] + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + clean = dict(tool_call) + function = clean.get("function") + if isinstance(function, dict): + clean_function = dict(function) + arguments = clean_function.get("arguments") + if not isinstance(arguments, str): + clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str) + clean["function"] = clean_function sanitized.append(clean) return sanitized @@ -155,6 +174,18 @@ class LiteLLMProvider(LLMProvider): if provider_payload: kwargs["provider"] = provider_payload + def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None: + if enabled is None: + return + model_key = f"{original_model} {resolved_model}".lower() + if "qwen" not in model_key: + return + extra_body = dict(kwargs.get("extra_body") or {}) + chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {}) + chat_template_kwargs["enable_thinking"] = bool(enabled) + extra_body["chat_template_kwargs"] = chat_template_kwargs + kwargs["extra_body"] = extra_body + async def chat( self, messages: list[dict[str, Any]], @@ -162,6 +193,7 @@ class LiteLLMProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + thinking_enabled: bool | None = None, ) -> LLMResponse: if acompletion is None: return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name) @@ -174,6 +206,7 @@ class LiteLLMProvider(LLMProvider): "messages": sanitized_messages, "max_tokens": max(1, max_tokens), "temperature": temperature, + "timeout": self.request_timeout_seconds or 45.0, } if self.api_key: kwargs["api_key"] = self.api_key @@ -186,6 +219,7 @@ class LiteLLMProvider(LLMProvider): kwargs["tool_choice"] = "auto" self._apply_model_overrides(original_model, kwargs) self._apply_openrouter_routing(kwargs) + self._apply_thinking_mode(original_model, resolved_model, kwargs, thinking_enabled) env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model) try: diff --git a/app-instance/backend/beaver/engine/session/manager.py b/app-instance/backend/beaver/engine/session/manager.py index 4c45317..a169baf 100644 --- a/app-instance/backend/beaver/engine/session/manager.py +++ b/app-instance/backend/beaver/engine/session/manager.py @@ -121,7 +121,37 @@ class SessionManager: 3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段” """ - history = self.get_messages_as_conversation(session_id) + records = self.get_event_records(session_id) + completed_run_ids = { + record.run_id + for record in records + if record.run_id and record.event_type == "run_completed" + } + failed_run_ids = { + record.run_id + for record in records + if record.run_id + and record.event_type == "run_completed" + and ( + record.finish_reason == "error" + or (record.event_payload or {}).get("finish_reason") == "error" + ) + } + history = [] + for record in records: + if not record.context_visible or record.role == "system": + continue + if record.role == "tool": + continue + if record.role == "assistant" and record.tool_calls: + continue + if record.run_id and record.run_id not in completed_run_ids: + continue + if record.run_id and record.run_id in failed_run_ids: + continue + if record.role == "assistant" and record.finish_reason == "error": + continue + history.append(record.to_conversation_message()) sliced = history[-max_messages:] for index, message in enumerate(sliced): if message.get("role") == "user": diff --git a/app-instance/backend/beaver/engine/session/models.py b/app-instance/backend/beaver/engine/session/models.py index 14f8f8e..6ae2e01 100644 --- a/app-instance/backend/beaver/engine/session/models.py +++ b/app-instance/backend/beaver/engine/session/models.py @@ -88,6 +88,15 @@ class MessageRecord: payload["feedback_state"] = self.event_payload.get("feedback_state") if self.event_payload.get("feedback_error"): payload["feedback_error"] = self.event_payload.get("feedback_error") + for key in ( + "message_type", + "scheduled_job_id", + "scheduled_run_id", + "cron_job_name", + "mode", + ): + if self.event_payload.get(key): + payload[key] = self.event_payload.get(key) if self.tool_name: payload["tool_name"] = self.tool_name if self.tool_calls: diff --git a/app-instance/backend/beaver/engine/session/search.py b/app-instance/backend/beaver/engine/session/search.py index f1d6d65..d0b6b48 100644 --- a/app-instance/backend/beaver/engine/session/search.py +++ b/app-instance/backend/beaver/engine/session/search.py @@ -70,6 +70,7 @@ class SessionSearchService: include_children: bool = False, source: str | None = None, exclude_sources: list[str] | None = None, + exclude_end_reasons: list[str] | None = None, ) -> list[dict[str, Any]]: """列出最近活跃的 session 及其摘要元数据。""" @@ -85,6 +86,10 @@ class SessionSearchService: placeholders = ",".join("?" for _ in exclude_sources) clauses.append(f"source NOT IN ({placeholders})") params.extend(exclude_sources) + if exclude_end_reasons: + placeholders = ",".join("?" for _ in exclude_end_reasons) + clauses.append(f"(end_reason IS NULL OR end_reason NOT IN ({placeholders}))") + params.extend(exclude_end_reasons) where = f"WHERE {' AND '.join(clauses)}" if clauses else "" params.extend([limit, offset]) diff --git a/app-instance/backend/beaver/engine/session/store.py b/app-instance/backend/beaver/engine/session/store.py index 6a6f4ec..853b1f5 100644 --- a/app-instance/backend/beaver/engine/session/store.py +++ b/app-instance/backend/beaver/engine/session/store.py @@ -128,19 +128,46 @@ class SessionStore: self._conn.executescript(SCHEMA_SQL) try: self._conn.execute("SELECT * FROM messages_fts LIMIT 0") - except sqlite3.OperationalError: - self._conn.executescript(FTS_TABLE_SQL) - self._conn.executescript(FTS_TRIGGER_SQL) + self._conn.executescript(FTS_TRIGGER_SQL) + except sqlite3.Error: + self._rebuild_fts_index() + return # 旧版本可能把 hidden 事件也写进了 FTS;初始化时顺手清掉这些噪声项。 - self._conn.execute( - """ - INSERT INTO messages_fts(messages_fts, rowid, content) - SELECT 'delete', id, content - FROM messages - WHERE context_visible = 0 AND content IS NOT NULL - """ - ) - self._conn.commit() + try: + self._conn.execute( + """ + INSERT INTO messages_fts(messages_fts, rowid, content) + SELECT 'delete', id, content + FROM messages + WHERE context_visible = 0 AND content IS NOT NULL + """ + ) + self._conn.commit() + except sqlite3.Error: + self._rebuild_fts_index() + + def _rebuild_fts_index(self) -> None: + """Recreate the derived FTS index without touching canonical session rows.""" + + self._conn.executescript( + """ + DROP TRIGGER IF EXISTS messages_fts_insert; + DROP TRIGGER IF EXISTS messages_fts_delete; + DROP TRIGGER IF EXISTS messages_fts_update; + DROP TABLE IF EXISTS messages_fts; + """ + ) + self._conn.executescript(FTS_TABLE_SQL) + self._conn.executescript(FTS_TRIGGER_SQL) + self._conn.execute( + """ + INSERT INTO messages_fts(rowid, content) + SELECT id, content + FROM messages + WHERE context_visible = 1 AND content IS NOT NULL + """ + ) + self._conn.commit() def close(self) -> None: with self._lock: diff --git a/app-instance/backend/beaver/foundation/config/__init__.py b/app-instance/backend/beaver/foundation/config/__init__.py index 6d2c228..c3c1aa1 100644 --- a/app-instance/backend/beaver/foundation/config/__init__.py +++ b/app-instance/backend/beaver/foundation/config/__init__.py @@ -1,13 +1,26 @@ """Configuration models and loaders.""" from .loader import default_config_path, load_config -from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig +from .schema import ( + AgentDefaultsConfig, + AuthzConfig, + BackendIdentityConfig, + BeaverConfig, + EmbeddingConfig, + MCPServerConfig, + ProviderConfig, + ToolsConfig, +) __all__ = [ "AgentDefaultsConfig", + "AuthzConfig", + "BackendIdentityConfig", "BeaverConfig", "EmbeddingConfig", + "MCPServerConfig", "ProviderConfig", + "ToolsConfig", "default_config_path", "load_config", ] diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index 8f179e6..5d40dec 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -4,10 +4,30 @@ from __future__ import annotations import json import os +import sys from pathlib import Path from typing import Any -from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig +from .schema import ( + AgentDefaultsConfig, + AuthzConfig, + BackendIdentityConfig, + BeaverConfig, + EmbeddingConfig, + MCPServerConfig, + ProviderConfig, + ToolsConfig, +) + +LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = { + "local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"}, + "local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"}, + "local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"}, + "local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"}, + "local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"}, + "local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"}, + "local_web_mcp": {"category": "web", "display_name": "本地联网工具"}, +} def default_config_path(*, workspace: str | Path | None = None) -> Path: @@ -57,6 +77,9 @@ def load_config( agents_defaults=_parse_agent_defaults(data), providers=_parse_providers(data.get("providers")), embedding=_parse_embedding(data), + tools=_parse_tools(data.get("tools")), + authz=_parse_authz(data.get("authz")), + backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), config_path=path, ) @@ -104,6 +127,73 @@ def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig: ) +def _parse_tools(raw: Any) -> ToolsConfig: + data = _as_dict(raw) + mcp_servers: dict[str, MCPServerConfig] = {} + for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items(): + if not isinstance(payload, dict): + continue + mcp_servers[str(server_id)] = MCPServerConfig( + command=_string(payload.get("command")) or "", + args=_string_list(payload.get("args")), + env=_string_dict(payload.get("env")), + url=_string(payload.get("url")) or "", + headers=_string_dict(payload.get("headers")), + auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(), + auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "", + auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")), + tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30), + sensitive=_bool(payload.get("sensitive"), default=False), + kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(), + category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"), + managed=_bool(payload.get("managed"), default=False), + display_name=_string(payload.get("displayName") or payload.get("display_name")) or "", + source=_string(payload.get("source")) or "config", + ) + for server_id, meta in LOCAL_MCP_CATEGORIES.items(): + if server_id in mcp_servers: + continue + mcp_servers[server_id] = MCPServerConfig( + command=sys.executable or "python", + args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]], + env={}, + kind="local", + category=meta["category"], + managed=True, + display_name=meta["display_name"], + source="beaver-default", + tool_timeout=60, + ) + return ToolsConfig( + restrict_to_workspace=_bool( + data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"), + default=True, + ), + mcp_servers=mcp_servers, + ) + + +def _parse_authz(raw: Any) -> AuthzConfig: + data = _as_dict(raw) + return AuthzConfig( + enabled=_bool(data.get("enabled"), default=False), + base_url=_string(data.get("baseUrl") or data.get("base_url")) or "", + request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10), + outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "", + ) + + +def _parse_backend_identity(raw: Any) -> BackendIdentityConfig: + data = _as_dict(raw) + return BackendIdentityConfig( + backend_id=_string(data.get("backendId") or data.get("backend_id")) or "", + client_id=_string(data.get("clientId") or data.get("client_id")) or "", + client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "", + name=_string(data.get("name")) or "", + public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "", + ) + + def _as_dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} @@ -121,7 +211,23 @@ def _string_dict(value: Any) -> dict[str, str]: return {str(key): str(item) for key, item in value.items() if item is not None} +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + def _float(value: Any) -> float | None: if value in (None, ""): return None return float(value) + + +def _bool(value: Any, *, default: bool) -> bool: + if isinstance(value, bool): + return value + if value in (None, ""): + return default + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) diff --git a/app-instance/backend/beaver/foundation/config/schema.py b/app-instance/backend/beaver/foundation/config/schema.py index 7344982..61f0d7f 100644 --- a/app-instance/backend/beaver/foundation/config/schema.py +++ b/app-instance/backend/beaver/foundation/config/schema.py @@ -39,6 +39,65 @@ class EmbeddingConfig: request_timeout_seconds: float | None = None +@dataclass(slots=True) +class MCPServerConfig: + """One configured MCP server. + + Transport is inferred from fields: + - command => local stdio MCP server + - url => remote streamable HTTP MCP server + """ + + command: str = "" + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + url: str = "" + headers: dict[str, str] = field(default_factory=dict) + auth_mode: str = "none" + auth_audience: str = "" + auth_scopes: list[str] = field(default_factory=list) + tool_timeout: int = 30 + sensitive: bool = False + kind: str = "online" + category: str = "online" + managed: bool = False + display_name: str = "" + source: str = "config" + + @property + def transport(self) -> str: + return "stdio" if _clean(self.command) else "http" + + +@dataclass(slots=True) +class ToolsConfig: + """Runtime tool configuration.""" + + restrict_to_workspace: bool = True + mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict) + + +@dataclass(slots=True) +class AuthzConfig: + """External AuthZ service configuration.""" + + enabled: bool = False + base_url: str = "" + request_timeout_seconds: int = 10 + outlook_mcp_url: str = "" + + +@dataclass(slots=True) +class BackendIdentityConfig: + """This backend's AuthZ client identity.""" + + backend_id: str = "" + client_id: str = "" + client_secret: str = "" + name: str = "" + public_base_url: str = "" + + @dataclass(slots=True) class BeaverConfig: """Config loaded once per backend sandbox instance.""" @@ -46,6 +105,9 @@ class BeaverConfig: agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig) providers: dict[str, ProviderConfig] = field(default_factory=dict) embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) + tools: ToolsConfig = field(default_factory=ToolsConfig) + authz: AuthzConfig = field(default_factory=AuthzConfig) + backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) config_path: Path | None = None @property @@ -69,7 +131,13 @@ class BeaverConfig: """ resolved_model = _clean(model) or self.default_model - resolved_provider = _clean(provider_name) or self._infer_provider(resolved_model) + requested_provider = _clean(provider_name) + enabled_providers = self._enabled_provider_names() + resolved_provider = ( + requested_provider + if requested_provider and requested_provider in enabled_providers + else self._infer_provider(resolved_model) + ) provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None payload: dict[str, Any] = { "model": resolved_model, @@ -115,22 +183,36 @@ class BeaverConfig: def _infer_provider(self, model: str | None) -> str | None: configured_provider = _clean(self.agents_defaults.provider) - if configured_provider: + if configured_provider and configured_provider != "custom": return configured_provider if model and "/" in model: prefix = model.split("/", 1)[0] - if prefix in self.providers: + if prefix in self._enabled_provider_names(): return prefix - if len(self.providers) == 1: - return next(iter(self.providers)) + enabled_providers = self._enabled_provider_names() + if len(enabled_providers) == 1: + return enabled_providers[0] return None + def _enabled_provider_names(self) -> list[str]: + return [ + name + for name, provider in self.providers.items() + if name != "custom" + and any( + [ + _clean(provider.api_key), + _clean(provider.api_base), + provider.extra_headers, + ] + ) + ] + def _clean(value: str | None) -> str | None: if value is None: return None value = str(value).strip() return value or None - diff --git a/app-instance/backend/beaver/foundation/embedding.py b/app-instance/backend/beaver/foundation/embedding.py index bb2b39d..77735b1 100644 --- a/app-instance/backend/beaver/foundation/embedding.py +++ b/app-instance/backend/beaver/foundation/embedding.py @@ -19,7 +19,7 @@ class EmbeddingRetriever: api_key_env: str = "OPENAI_API_KEY", api_base_env: str = "OPENAI_API_BASE", model: str = "text-embedding-v4", - timeout_seconds: float = 20.0, + timeout_seconds: float = 3.0, ) -> None: self.api_key_env = api_key_env self.api_base_env = api_base_env diff --git a/app-instance/backend/beaver/foundation/models/__init__.py b/app-instance/backend/beaver/foundation/models/__init__.py index d8bdfd3..cdc94c1 100644 --- a/app-instance/backend/beaver/foundation/models/__init__.py +++ b/app-instance/backend/beaver/foundation/models/__init__.py @@ -1,2 +1,11 @@ -"""Shared data models.""" +"""Shared Beaver data models.""" +from .cron import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule + +__all__ = [ + "CronExecutionResult", + "CronJob", + "CronPayload", + "CronRunRecord", + "CronSchedule", +] diff --git a/app-instance/backend/beaver/foundation/models/cron.py b/app-instance/backend/beaver/foundation/models/cron.py new file mode 100644 index 0000000..f2ba924 --- /dev/null +++ b/app-instance/backend/beaver/foundation/models/cron.py @@ -0,0 +1,266 @@ +"""Scheduled task models for Beaver cron. + +The scheduler borrows Hermes' durable JSON + explicit schedule parsing shape, +but the execution target is Beaver Task mode: every trigger creates a normal +Task run instead of a detached agent turn. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal +from uuid import uuid4 + + +CronScheduleKind = Literal["at", "every", "cron"] +CronPayloadKind = Literal["agent_turn", "system_event"] +CronPayloadMode = Literal["notification", "task"] + + +@dataclass(slots=True) +class CronSchedule: + kind: CronScheduleKind + at_ms: int | None = None + every_ms: int | None = None + expr: str | None = None + tz: str | None = None + display: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "at_ms": self.at_ms, + "every_ms": self.every_ms, + "expr": self.expr, + "tz": self.tz, + "display": self.display, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule": + return cls( + kind=str(payload.get("kind") or "every"), # type: ignore[arg-type] + at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")), + every_ms=_optional_int(payload.get("every_ms") or payload.get("everyMs")), + expr=_optional_str(payload.get("expr")), + tz=_optional_str(payload.get("tz")), + display=_optional_str(payload.get("display")), + ) + + +@dataclass(slots=True) +class CronPayload: + kind: CronPayloadKind = "agent_turn" + mode: CronPayloadMode = "notification" + message: str = "" + session_key: str | None = None + requires_followup: bool = False + deliver: bool = False + channel: str | None = None + to: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "mode": self.mode, + "message": self.message, + "session_key": self.session_key, + "requires_followup": self.requires_followup, + "deliver": self.deliver, + "channel": self.channel, + "to": self.to, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "CronPayload": + return cls( + kind=str(payload.get("kind") or "agent_turn"), # type: ignore[arg-type] + mode=_payload_mode(payload.get("mode"), default="task"), + message=str(payload.get("message") or ""), + session_key=_optional_str(payload.get("session_key") or payload.get("sessionKey")), + requires_followup=bool(payload.get("requires_followup") or payload.get("requiresFollowup") or False), + deliver=bool(payload.get("deliver", False)), + channel=_optional_str(payload.get("channel")), + to=_optional_str(payload.get("to")), + ) + + +@dataclass(slots=True) +class CronRunRecord: + started_at_ms: int + scheduled_run_id: str = field(default_factory=lambda: uuid4().hex) + finished_at_ms: int | None = None + status: Literal["running", "ok", "error", "skipped"] = "running" + mode: CronPayloadMode = "notification" + notification_session_id: str | None = None + output: str | None = None + task_id: str | None = None + run_id: str | None = None + error: str | None = None + engaged: bool = False + engaged_at_ms: int | None = None + engage_intent: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "scheduled_run_id": self.scheduled_run_id, + "started_at_ms": self.started_at_ms, + "finished_at_ms": self.finished_at_ms, + "status": self.status, + "mode": self.mode, + "notification_session_id": self.notification_session_id, + "output": self.output, + "task_id": self.task_id, + "run_id": self.run_id, + "error": self.error, + "engaged": self.engaged, + "engaged_at_ms": self.engaged_at_ms, + "engage_intent": self.engage_intent, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "CronRunRecord": + return cls( + scheduled_run_id=str(payload.get("scheduled_run_id") or payload.get("scheduledRunId") or uuid4().hex), + started_at_ms=int(payload.get("started_at_ms") or payload.get("startedAtMs") or 0), + finished_at_ms=_optional_int(payload.get("finished_at_ms") or payload.get("finishedAtMs")), + status=str(payload.get("status") or "running"), # type: ignore[arg-type] + mode=_payload_mode(payload.get("mode"), default="notification"), + notification_session_id=_optional_str(payload.get("notification_session_id") or payload.get("notificationSessionId")), + output=_optional_str(payload.get("output")), + task_id=_optional_str(payload.get("task_id") or payload.get("taskId")), + run_id=_optional_str(payload.get("run_id") or payload.get("runId")), + error=_optional_str(payload.get("error")), + engaged=bool(payload.get("engaged", False)), + engaged_at_ms=_optional_int(payload.get("engaged_at_ms") or payload.get("engagedAtMs")), + engage_intent=_optional_str(payload.get("engage_intent") or payload.get("engageIntent")), + ) + + +@dataclass(slots=True) +class CronJob: + id: str + name: str + enabled: bool + schedule: CronSchedule + payload: CronPayload + created_at_ms: int + updated_at_ms: int + next_run_at_ms: int | None = None + last_run_at_ms: int | None = None + last_status: Literal["ok", "error", "skipped"] | None = None + last_error: str | None = None + delete_after_run: bool = False + history: list[CronRunRecord] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "enabled": self.enabled, + "schedule": self.schedule.to_dict(), + "payload": self.payload.to_dict(), + "created_at_ms": self.created_at_ms, + "updated_at_ms": self.updated_at_ms, + "next_run_at_ms": self.next_run_at_ms, + "last_run_at_ms": self.last_run_at_ms, + "last_status": self.last_status, + "last_error": self.last_error, + "delete_after_run": self.delete_after_run, + "history": [item.to_dict() for item in self.history], + } + + def to_api_dict(self) -> dict[str, Any]: + latest = self.history[-1] if self.history else None + return { + "id": self.id, + "name": self.name, + "enabled": self.enabled, + "schedule_kind": self.schedule.kind, + "schedule_display": self.schedule.display or _schedule_display(self.schedule), + "schedule_expr": self.schedule.expr, + "schedule_every_ms": self.schedule.every_ms, + "message": self.payload.message, + "mode": self.payload.mode, + "requires_followup": self.payload.requires_followup, + "deliver": self.payload.deliver, + "channel": self.payload.channel, + "to": self.payload.to, + "session_key": self.payload.session_key, + "next_run_at_ms": self.next_run_at_ms, + "last_run_at_ms": self.last_run_at_ms, + "last_status": self.last_status, + "last_error": self.last_error, + "last_scheduled_run_id": latest.scheduled_run_id if latest else None, + "last_task_id": latest.task_id if latest else None, + "last_run_id": latest.run_id if latest else None, + "history": [item.to_dict() for item in self.history], + "created_at_ms": self.created_at_ms, + "updated_at_ms": self.updated_at_ms, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "CronJob": + schedule_payload = payload.get("schedule") if isinstance(payload.get("schedule"), dict) else {} + payload_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {} + return cls( + id=str(payload["id"]), + name=str(payload.get("name") or payload["id"]), + enabled=bool(payload.get("enabled", True)), + schedule=CronSchedule.from_dict(schedule_payload), + payload=CronPayload.from_dict(payload_payload), + created_at_ms=int(payload.get("created_at_ms") or payload.get("createdAtMs") or 0), + updated_at_ms=int(payload.get("updated_at_ms") or payload.get("updatedAtMs") or 0), + next_run_at_ms=_optional_int(payload.get("next_run_at_ms") or payload.get("nextRunAtMs")), + last_run_at_ms=_optional_int(payload.get("last_run_at_ms") or payload.get("lastRunAtMs")), + last_status=_optional_str(payload.get("last_status") or payload.get("lastStatus")), # type: ignore[arg-type] + last_error=_optional_str(payload.get("last_error") or payload.get("lastError")), + delete_after_run=bool(payload.get("delete_after_run") or payload.get("deleteAfterRun") or False), + history=[ + CronRunRecord.from_dict(item) + for item in payload.get("history") or [] + if isinstance(item, dict) + ], + ) + + +@dataclass(slots=True) +class CronExecutionResult: + response: str | None = None + task_id: str | None = None + run_id: str | None = None + notification_session_id: str | None = None + mode: CronPayloadMode = "notification" + + +def _schedule_display(schedule: CronSchedule) -> str: + if schedule.kind == "every": + seconds = int((schedule.every_ms or 0) / 1000) + return f"every {seconds}s" + if schedule.kind == "cron": + return schedule.expr or "cron" + return "one-time" + + +def _optional_str(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) + + +def _optional_int(value: Any) -> int | None: + if value in (None, ""): + return None + + +def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode: + if value in (None, ""): + return default + cleaned = str(value or "").strip().lower() + if cleaned == "task": + return "task" + return "notification" + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/app-instance/backend/beaver/integrations/authz/__init__.py b/app-instance/backend/beaver/integrations/authz/__init__.py new file mode 100644 index 0000000..19cec79 --- /dev/null +++ b/app-instance/backend/beaver/integrations/authz/__init__.py @@ -0,0 +1,5 @@ +"""AuthZ service client integration.""" + +from .client import AuthzClient + +__all__ = ["AuthzClient"] diff --git a/app-instance/backend/beaver/integrations/authz/client.py b/app-instance/backend/beaver/integrations/authz/client.py new file mode 100644 index 0000000..3543487 --- /dev/null +++ b/app-instance/backend/beaver/integrations/authz/client.py @@ -0,0 +1,50 @@ +"""Small async client for the internal AuthZ service.""" + +from __future__ import annotations + +from typing import Any + +import httpx + + +class AuthzClient: + def __init__(self, base_url: str, timeout_seconds: int = 10) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + async def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any: + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + follow_redirects=True, + trust_env=False, + ) as client: + response = await client.request(method, f"{self.base_url}{path}", json=json_body) + response.raise_for_status() + if not response.content: + return None + return response.json() + + async def issue_token( + self, + *, + client_id: str, + client_secret: str, + audience: str, + scopes: list[str], + ) -> dict[str, Any]: + data = await self._request( + "POST", + "/oauth/token", + json_body={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "aud": audience, + "scopes": list(scopes), + }, + ) + return data if isinstance(data, dict) else {} + + async def get_permissions(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/permissions") + return data if isinstance(data, dict) else {} diff --git a/app-instance/backend/beaver/integrations/mcp/__init__.py b/app-instance/backend/beaver/integrations/mcp/__init__.py index 0adc85b..1e83318 100644 --- a/app-instance/backend/beaver/integrations/mcp/__init__.py +++ b/app-instance/backend/beaver/integrations/mcp/__init__.py @@ -1,2 +1,5 @@ """MCP integration.""" +from .connection import MCPConnectionManager, test_mcp_server + +__all__ = ["MCPConnectionManager", "test_mcp_server"] diff --git a/app-instance/backend/beaver/integrations/mcp/connection.py b/app-instance/backend/beaver/integrations/mcp/connection.py new file mode 100644 index 0000000..f3b015d --- /dev/null +++ b/app-instance/backend/beaver/integrations/mcp/connection.py @@ -0,0 +1,192 @@ +"""MCP connection manager.""" + +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack +from dataclasses import dataclass, field +from typing import Any + +import httpx + +from beaver.foundation.config import AuthzConfig, BackendIdentityConfig, MCPServerConfig +from beaver.integrations.authz import AuthzClient +from beaver.tools.mcp.wrapper import MCPToolWrapper +from beaver.tools.registry import ToolRegistry + + +@dataclass(slots=True) +class MCPConnectionReport: + status: str = "disconnected" + last_error: str | None = None + tool_names: list[str] = field(default_factory=list) + tool_count: int = 0 + transport: str = "http" + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "last_error": self.last_error, + "tool_names": list(self.tool_names), + "tool_count": self.tool_count, + "transport": self.transport, + } + + +class MCPConnectionManager: + def __init__( + self, + servers: dict[str, MCPServerConfig], + *, + authz_config: AuthzConfig | None = None, + backend_identity: BackendIdentityConfig | None = None, + ) -> None: + self.servers = servers + self.authz_config = authz_config + self.backend_identity = backend_identity + self.stack = AsyncExitStack() + self.connected = False + self._connect_lock = asyncio.Lock() + self.report: dict[str, MCPConnectionReport] = {} + + async def connect_all(self, registry: ToolRegistry) -> dict[str, dict[str, Any]]: + async with self._connect_lock: + if self.connected: + return {key: value.to_dict() for key, value in self.report.items()} + self.report = {} + for server_id, cfg in self.servers.items(): + self.report[server_id] = MCPConnectionReport(transport=cfg.transport) + try: + if cfg.command: + await self._connect_stdio(server_id, cfg, registry) + elif cfg.url: + await self._connect_http(server_id, cfg, registry) + else: + raise ValueError("MCP server requires command or url") + self.report[server_id].status = "connected" + self.report[server_id].tool_count = len(self.report[server_id].tool_names) + except Exception as exc: + self.report[server_id].status = "error" + self.report[server_id].last_error = _describe_exception(exc, server_id=server_id, url=cfg.url or None) + self.connected = True + return {key: value.to_dict() for key, value in self.report.items()} + + async def close(self) -> None: + await self.stack.aclose() + self.connected = False + + async def _headers(self, server_id: str, cfg: MCPServerConfig) -> dict[str, str]: + headers = dict(cfg.headers or {}) + if cfg.auth_mode != "oauth_backend_token": + return headers + if not ( + self.authz_config + and self.authz_config.enabled + and self.authz_config.base_url + and self.backend_identity + and self.backend_identity.client_id + and self.backend_identity.client_secret + ): + raise RuntimeError("oauth_backend_token requires AuthZ and backend identity") + audience = cfg.auth_audience or f"mcp:{server_id}" + client = AuthzClient(self.authz_config.base_url, timeout_seconds=self.authz_config.request_timeout_seconds) + token = await client.issue_token( + client_id=self.backend_identity.client_id, + client_secret=self.backend_identity.client_secret, + audience=audience, + scopes=list(cfg.auth_scopes), + ) + access_token = str(token.get("access_token") or "").strip() + if not access_token: + raise RuntimeError("AuthZ did not return an access token") + headers["Authorization"] = f"Bearer {access_token}" + return headers + + async def _open_http_session(self, cfg: MCPServerConfig, headers: dict[str, str]): + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + http_client = await self.stack.enter_async_context( + httpx.AsyncClient(headers=headers or None, follow_redirects=True, trust_env=False) + ) + read, write, _ = await self.stack.enter_async_context(streamable_http_client(cfg.url, http_client=http_client)) + session = await self.stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + return session + + async def _connect_http(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None: + headers = await self._headers(server_id, cfg) + session = await self._open_http_session(cfg, headers) + tools = await session.list_tools() + for tool_def in tools.tools: + async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any: + return await _session.call_tool(tool_name, arguments=args) + + wrapper = MCPToolWrapper( + server_id, + tool_def, + call_tool, + cfg.tool_timeout, + cfg.sensitive, + cfg.kind, + cfg.category, + cfg.display_name, + ) + registry.register(wrapper, replace=True) + if wrapper.spec.name not in self.report[server_id].tool_names: + self.report[server_id].tool_names.append(wrapper.spec.name) + + async def _connect_stdio(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + params = StdioServerParameters(command=cfg.command, args=list(cfg.args), env=dict(cfg.env) or None) + read, write = await self.stack.enter_async_context(stdio_client(params)) + session = await self.stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + tools = await session.list_tools() + for tool_def in tools.tools: + async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any: + return await _session.call_tool(tool_name, arguments=args) + + wrapper = MCPToolWrapper( + server_id, + tool_def, + call_tool, + cfg.tool_timeout, + cfg.sensitive, + cfg.kind, + cfg.category, + cfg.display_name, + ) + registry.register(wrapper, replace=True) + if wrapper.spec.name not in self.report[server_id].tool_names: + self.report[server_id].tool_names.append(wrapper.spec.name) + + +async def test_mcp_server( + server_id: str, + cfg: MCPServerConfig, + *, + authz_config: AuthzConfig | None = None, + backend_identity: BackendIdentityConfig | None = None, +) -> dict[str, Any]: + registry = ToolRegistry() + manager = MCPConnectionManager({server_id: cfg}, authz_config=authz_config, backend_identity=backend_identity) + try: + report = await manager.connect_all(registry) + return {"ok": report.get(server_id, {}).get("status") == "connected", "server": server_id, **report.get(server_id, {})} + finally: + await manager.close() + + +def _describe_exception(exc: BaseException, *, server_id: str, url: str | None = None) -> str: + target = f" ({url})" if url else "" + if isinstance(exc, httpx.TimeoutException): + return f"MCP server '{server_id}' timed out{target}" + if isinstance(exc, httpx.ConnectError): + return f"MCP server '{server_id}' is unreachable{target}" + if isinstance(exc, httpx.HTTPStatusError): + return f"MCP server '{server_id}' returned HTTP {exc.response.status_code}{target}" + detail = str(exc).strip() or exc.__class__.__name__ + return f"MCP server '{server_id}' failed{target}: {detail}" diff --git a/app-instance/backend/beaver/interfaces/channels/memory.py b/app-instance/backend/beaver/interfaces/channels/memory.py index a70a553..c7702b5 100644 --- a/app-instance/backend/beaver/interfaces/channels/memory.py +++ b/app-instance/backend/beaver/interfaces/channels/memory.py @@ -55,3 +55,37 @@ class MemoryChannelAdapter: await self.bus.publish_inbound(message) return message + async def publish_external_text( + self, + content: str, + *, + chat_id: str, + message_id: str | None = None, + thread_id: str | None = None, + raw_payload: dict[str, Any] | None = None, + user_id: str | None = None, + title: str | None = None, + ) -> InboundMessage: + """Publish an old-style channel payload through the new adapter contract. + + Real platform adapters should keep platform-specific fields here, build + a stable Beaver session_id, and pass the normalized InboundMessage to + the shared gateway bus. + """ + + session_parts = [self.name, chat_id] + if thread_id: + session_parts.append(thread_id) + metadata = { + "chat_id": chat_id, + "message_id": message_id, + "thread_id": thread_id, + "raw_channel_payload": raw_payload or {}, + } + return await self.publish_text( + content, + session_id=":".join(str(part) for part in session_parts if str(part)), + user_id=user_id, + title=title, + metadata=metadata, + ) diff --git a/app-instance/backend/beaver/interfaces/cli/main.py b/app-instance/backend/beaver/interfaces/cli/main.py index 5a7d131..d9897ab 100644 --- a/app-instance/backend/beaver/interfaces/cli/main.py +++ b/app-instance/backend/beaver/interfaces/cli/main.py @@ -1,5 +1,7 @@ """CLI entry for Beaver.""" +from pathlib import Path + try: import typer except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments @@ -27,6 +29,8 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env typer = _FallbackTyper() # type: ignore[assignment] from beaver.services.agent_service import AgentService +from beaver.services.hermes_migration import HermesMigrationService +from beaver.skills.specs import SkillSpecStore app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer @@ -55,6 +59,26 @@ def run( typer.echo(result.output_text) +@app.command("migrate-hermes") +def migrate_hermes( + repo: str = typer.Option(..., "--repo", help="Local checkout of https://github.com/NousResearch/hermes-agent."), + workspace: str | None = typer.Option(None, "--workspace", help="Workspace root to import skills into."), + manifest: str | None = typer.Option(None, "--manifest", help="Path for hermes_migration_manifest.json."), + dry_run: bool = typer.Option(False, "--dry-run", help="Only write the manifest without importing skills."), +) -> None: + """Import no-credential Hermes Agent skills and write a manifest.""" + + service = AgentService(workspace=workspace) + loaded = service.create_loop().boot() + store = loaded.skill_spec_store or SkillSpecStore(loaded.workspace) + migration = HermesMigrationService(store, manifest_path=Path(manifest) if manifest else None) + result = migration.migrate(repo, dry_run=dry_run) + typer.echo( + f"Hermes migration complete: {len(result['included'])} included, " + f"{len(result['skipped'])} skipped." + ) + + def main() -> None: """Project script entrypoint.""" app() diff --git a/app-instance/backend/beaver/interfaces/mcp/tools_server.py b/app-instance/backend/beaver/interfaces/mcp/tools_server.py new file mode 100644 index 0000000..2d2446b --- /dev/null +++ b/app-instance/backend/beaver/interfaces/mcp/tools_server.py @@ -0,0 +1,192 @@ +"""Beaver local tools as real stdio MCP servers.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server +from mcp.server.lowlevel.server import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.stdio import stdio_server + +from beaver.engine.session import SessionManager +from beaver.memory.curated.store import MemoryStore +from beaver.services.cron_service import CronService +from beaver.skills import SkillsLoader +from beaver.skills.drafts import DraftService +from beaver.skills.specs import SkillSpecStore +from beaver.tools.base import BaseTool, ObjectBackedTool, ToolContext +from beaver.tools.builtins import ( + ClarifyTool, + CronTool, + DelegateTool, + ExecuteCodeTool, + ListDirectoryTool, + MemoryTool, + PatchFileTool, + ProcessTool, + ReadFileTool, + SearchFilesTool, + SendMessageTool, + SkillManageTool, + SkillViewTool, + SkillsListTool, + SpawnTool, + TerminalTool, + TodoTool, + WebFetchTool, + WebSearchTool, + WriteFileTool, +) + + +LOCAL_TOOL_CATEGORIES = { + "filesystem": "Beaver Local Filesystem Tools", + "runtime": "Beaver Local Runtime Tools", + "memory": "Beaver Local Memory Tools", + "skills": "Beaver Local Skills Tools", + "coordination": "Beaver Local Coordination Tools", + "scheduler": "Beaver Local Scheduler Tools", + "web": "Beaver Local Web Tools", +} + + +def _workspace_path(value: str | None = None) -> Path: + raw = value or os.getenv("BEAVER_WORKSPACE") or os.getenv("NANOBOT_WORKSPACE") + if raw: + return Path(raw).expanduser().resolve() + return Path.cwd() + + +def _json_content(value: str) -> dict[str, Any]: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"success": True, "result": parsed} + except json.JSONDecodeError: + return {"success": True, "content": value} + + +def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], ToolContext]: + skill_store = SkillSpecStore(workspace) + skills_loader = SkillsLoader(workspace, skill_store=skill_store) + draft_service = DraftService(skill_store) + services = { + "skills_loader": skills_loader, + "draft_service": draft_service, + } + context = ToolContext(workspace=str(workspace), services=services) + + if category == "filesystem": + tools: list[BaseTool] = [ + ObjectBackedTool(ListDirectoryTool()), + ObjectBackedTool(ReadFileTool()), + ObjectBackedTool(SearchFilesTool()), + ObjectBackedTool(WriteFileTool()), + ObjectBackedTool(PatchFileTool()), + ] + elif category == "runtime": + tools = [ + ObjectBackedTool(TerminalTool()), + ObjectBackedTool(ProcessTool()), + ObjectBackedTool(ExecuteCodeTool()), + ] + elif category == "memory": + session_manager = SessionManager(workspace) + memory_store = MemoryStore(workspace / "memory" / "curated") + memory_store.load_from_disk() + tools = [ + ObjectBackedTool(MemoryTool(store=memory_store)), + ObjectBackedTool(__import__("beaver.tools.builtins.session_search", fromlist=["SessionSearchTool"]).SessionSearchTool(db=session_manager)), + ] + elif category == "skills": + tools = [ + ObjectBackedTool(SkillViewTool(loader=skills_loader)), + SkillsListTool(), + SkillManageTool(), + ] + elif category == "coordination": + tools = [ + ObjectBackedTool(TodoTool()), + ObjectBackedTool(ClarifyTool()), + ObjectBackedTool(DelegateTool()), + ObjectBackedTool(SpawnTool()), + ObjectBackedTool(SendMessageTool()), + ] + elif category == "scheduler": + services["cron_service"] = CronService(workspace / "cron" / "jobs.json") + tools = [CronTool()] + elif category == "web": + tools = [ + ObjectBackedTool(WebFetchTool()), + ObjectBackedTool(WebSearchTool()), + ] + else: + raise ValueError(f"Unknown local tool category: {category}") + return tools, context + + +def create_tools_server(*, category: str, workspace: str | None = None) -> Server: + workspace_path = _workspace_path(workspace) + tools, context = _category_tools(category, workspace_path) + tool_map = {tool.spec.name: tool for tool in tools} + server = Server(LOCAL_TOOL_CATEGORIES.get(category, f"Beaver Local {category} Tools")) + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name=tool.spec.name, + description=tool.spec.description, + inputSchema=tool.spec.input_schema, + ) + for tool in tools + ] + + @server.call_tool(validate_input=True) + async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + tool = tool_map.get(name) + if tool is None: + return {"success": False, "error": f"Unknown tool: {name}"} + result = await tool.invoke(arguments or {}, context) + if result.raw_output is not None and isinstance(result.raw_output, dict): + return result.raw_output + payload = _json_content(result.content) + if "success" not in payload: + payload["success"] = bool(result.success) + if result.error and "error" not in payload: + payload["error"] = result.error + return payload + + return server + + +async def _run_stdio(category: str, workspace: str | None) -> None: + server = create_tools_server(category=category, workspace=workspace) + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name=LOCAL_TOOL_CATEGORIES.get(category, f"beaver-{category}"), + server_version="0.1.0", + capabilities=server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}), + ), + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a Beaver local tool category as a stdio MCP server.") + parser.add_argument("--category", choices=sorted(LOCAL_TOOL_CATEGORIES), required=True) + parser.add_argument("--workspace", default=None) + args = parser.parse_args() + asyncio.run(_run_stdio(args.category, args.workspace)) + + +if __name__ == "__main__": + main() diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index 9c4cd32..a79dbcc 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -4,6 +4,12 @@ from __future__ import annotations import json import asyncio +import io +import os +import secrets +import shutil +import time +import zipfile from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager, suppress from pathlib import Path @@ -12,8 +18,14 @@ from typing import Any from beaver.engine.providers.registry import PROVIDERS, find_by_name from beaver.foundation.config import default_config_path, load_config -from beaver.services.agent_service import AgentService +from beaver.foundation.models import CronExecutionResult, CronRunRecord +from beaver.integrations.mcp import MCPConnectionManager +from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService +from beaver.services.cron_service import CronService, schedule_from_api +from beaver.services.skill_migration import SkillMigrationService +from beaver.services.skillhub_service import SkillHubService from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig +from beaver.skills.catalog.utils import parse_frontmatter from .deps import get_agent_service from .schemas import ( @@ -28,8 +40,15 @@ from .schemas import ( ) try: - from fastapi import FastAPI, HTTPException, Request + from fastapi import FastAPI, File, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect + from fastapi.responses import Response except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments + def File(default: Any = None) -> Any: # type: ignore[override] + return default + + def Header(default: Any = None) -> Any: # type: ignore[override] + return default + class HTTPException(Exception): """Minimal fallback exception matching FastAPI's constructor shape.""" @@ -44,6 +63,23 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env def __init__(self, app: Any) -> None: self.app = app + class UploadFile: # type: ignore[override] + filename: str | None + + class Response: # type: ignore[override] + def __init__(self, content: bytes, media_type: str | None = None, headers: dict[str, str] | None = None) -> None: + self.content = content + self.media_type = media_type + self.headers = headers or {} + + class WebSocketDisconnect(Exception): + """Fallback websocket disconnect exception.""" + + class WebSocket: # type: ignore[override] + """Fallback websocket shim used only so annotations import.""" + + app: Any + class FastAPI: # type: ignore[override] """Small fallback shim so the package can import before dependencies are installed.""" @@ -70,6 +106,18 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env return decorator + def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return func + + return decorator + + def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return func + + return decorator + def patch(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: def decorator(func: Callable[..., Any]) -> Callable[..., Any]: return func @@ -82,6 +130,12 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env return decorator + def websocket(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return func + + return decorator + @asynccontextmanager async def _app_lifespan( @@ -99,13 +153,16 @@ async def _app_lifespan( attached_service = service or AgentService(workspace=workspace, config_path=config_path) owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None app.state.agent_service = attached_service + app.state.cron_service = _build_cron_service(attached_service) if owns_service else None started = False if owns_service: try: await attached_service.start() + await app.state.cron_service.start() started = True except BaseException: with suppress(BaseException): + app.state.cron_service.stop() if attached_service.is_running: await attached_service.shutdown( timeout_seconds=shutdown_timeout_seconds, @@ -130,6 +187,9 @@ async def _app_lifespan( try: yield finally: + cron_service = getattr(app.state, "cron_service", None) + if isinstance(cron_service, CronService): + cron_service.stop() if worker is not None: worker.stop() if worker_task is not None: @@ -143,6 +203,57 @@ async def _app_lifespan( ) +def _build_cron_service(agent_service: AgentService) -> CronService: + loaded = agent_service.create_loop().boot() + + async def on_job(job: Any, run_record: CronRunRecord) -> CronExecutionResult: + if getattr(job.payload, "mode", "notification") == "notification": + result = await agent_service.run_scheduled_notification( + job.payload.message, + session_id=NOTIFICATION_SESSION_ID, + cron_job_id=job.id, + cron_job_name=job.name, + scheduled_run_id=run_record.scheduled_run_id, + ) + return CronExecutionResult( + response=result.output_text, + run_id=result.run_id, + notification_session_id=result.session_id, + mode="notification", + ) + + session_id = job.payload.session_key or f"cron:{job.id}" + result = await agent_service.run_scheduled_task( + job.payload.message, + session_id=session_id, + cron_job_id=job.id, + cron_job_name=job.name, + scheduled_run_id=run_record.scheduled_run_id, + requires_followup=bool(getattr(job.payload, "requires_followup", False)), + ) + return CronExecutionResult( + response=result.output_text, + task_id=result.task_id, + run_id=result.run_id, + notification_session_id=session_id, + mode="task", + ) + + service = CronService(loaded.workspace / "cron" / "jobs.json", on_job=on_job) + agent_service.register_runtime_service("cron_service", service) + return service + + +def get_cron_service(request: Request) -> CronService: + service = getattr(request.app.state, "cron_service", None) + if isinstance(service, CronService): + return service + agent_service = get_agent_service(request) + service = _build_cron_service(agent_service) + request.app.state.cron_service = service + return service + + def create_app( *, workspace: str | Path | None = None, @@ -173,6 +284,9 @@ def create_app( shutdown_force=shutdown_force, ), ) + app.state.auth_tokens = {} + app.state.handoff_codes = {} + app.state.auth_file = Path(os.getenv("NANOBOT_AUTH_FILE") or os.getenv("BEAVER_AUTH_FILE") or "") @app.get("/api/ping", response_model=WebStatusResponse) async def ping(request: Request) -> WebStatusResponse: @@ -190,14 +304,17 @@ def create_app( loaded = agent_service.create_loop().boot() config = loaded.config config_path = config.config_path or default_config_path(workspace=loaded.workspace) + cron_service = get_cron_service(request) providers_status = [] default_provider = config.resolve_provider_target().get("provider_name") for spec in PROVIDERS: + if spec.name == "custom": + continue provider_cfg = config.providers.get(spec.name) - enabled = provider_cfg is not None api_key = provider_cfg.api_key if provider_cfg is not None else None api_base = provider_cfg.api_base if provider_cfg is not None else None + enabled = _provider_enabled(spec.name, provider_cfg) if spec.is_oauth: has_key = enabled elif spec.is_local or spec.is_direct: @@ -233,9 +350,58 @@ def create_app( "max_tool_iterations": agent_service.profile.max_tool_iterations, "providers": providers_status, "channels": [{"name": "web", "enabled": True}], - "cron": {"enabled": False, "jobs": 0, "next_wake_at_ms": None}, + "cron": cron_service.status(), } + @app.post("/api/auth/login") + async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + username = _clean_text(payload.get("username")) + password = str(payload.get("password") or "") + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password are required") + + users = _load_auth_users(_auth_file_path()) + expected = users.get(username) + if expected is None or not secrets.compare_digest(expected, password): + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = _issue_web_token(app, username) + handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) + return { + "access_token": token, + "refresh_token": "", + "token_type": "bearer", + "user_id": username, + "username": username, + "role": "owner", + "handoff_code": handoff_code, + "handoff_expires_at": handoff_expires_at, + "backend_connection": _backend_connection_view(request), + "local_backend": _local_backend_view(), + } + + @app.post("/api/auth/handoff/consume") + async def auth_handoff_consume(payload: dict[str, Any]) -> dict[str, Any]: + return _consume_handoff_code(app, str(payload.get("code") or "")) + + @app.get("/api/auth/me") + async def auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]: + username = _require_web_user(app, authorization) + return { + "id": username, + "username": username, + "email": os.getenv("NANOBOT_BACKEND_IDENTITY__EMAIL", ""), + "role": "owner", + "quota_tier": "single-user", + } + + @app.post("/api/auth/logout") + async def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: + if authorization and authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + app.state.auth_tokens.pop(token, None) + return {"ok": True} + @app.post("/api/providers/{provider_name}/config", response_model=WebProviderConfigResponse) async def update_provider_config( provider_name: str, @@ -243,7 +409,7 @@ def create_app( payload: WebProviderConfigRequest, ) -> WebProviderConfigResponse: spec = find_by_name(provider_name) - if spec is None: + if spec is None or spec.name == "custom": raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_name}") agent_service = get_agent_service(request) @@ -274,6 +440,7 @@ def create_app( provider_payload.pop("apiBase", None) if payload.request_timeout_seconds is not None: provider_payload["requestTimeoutSeconds"] = payload.request_timeout_seconds + providers.clear() providers[spec.name] = provider_payload defaults["provider"] = spec.name model = _clean_text(payload.model) @@ -288,7 +455,11 @@ def create_app( async def list_sessions(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() session_manager = loaded.session_manager - rows = session_manager.list_sessions_rich(limit=100, exclude_sources=["subagent"]) # type: ignore[union-attr] + rows = session_manager.list_sessions_rich( + limit=100, + exclude_sources=["subagent", "notification"], + exclude_end_reasons=["archived", "deleted"], + ) # type: ignore[union-attr] return [ { "key": str(row.get("id")), @@ -299,6 +470,41 @@ def create_app( for row in rows ] + @app.get("/api/debug/chat-logs") + async def get_chat_logs(request: Request, limit: int = 50) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + session_manager = loaded.session_manager + bounded_limit = max(1, min(int(limit or 50), 200)) + rows = session_manager.list_sessions_rich( + limit=bounded_limit, + exclude_end_reasons=["archived", "deleted"], + ) # type: ignore[union-attr] + sessions = [] + for row in rows: + session_id = str(row.get("id")) + runs = _debug_runs_for_session(session_manager, session_id) + if not runs: + continue + sessions.append( + { + "session_id": session_id, + "source": row.get("source"), + "title": row.get("title"), + "created_at": _iso_from_timestamp(row.get("started_at")), + "updated_at": _iso_from_timestamp(row.get("last_active")), + "runs": runs, + } + ) + return {"sessions": sessions} + + @app.post("/api/sessions/{session_id:path}/archive") + async def archive_session(session_id: str, request: Request) -> dict[str, Any]: + if session_id.startswith("notify:"): + raise HTTPException(status_code=400, detail="Notification sessions cannot be archived") + loaded = get_agent_service(request).create_loop().boot() + loaded.session_manager.end_session(session_id, "archived") # type: ignore[union-attr] + return {"ok": True, "archived": True} + @app.post("/api/sessions/{session_id:path}") async def create_session(session_id: str, request: Request) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() @@ -317,6 +523,23 @@ def create_app( ) return projector.project(session_id) + @app.get("/api/sessions/{session_id:path}/active-task") + async def get_session_active_task(session_id: str, request: Request) -> dict[str, Any] | None: + loaded = get_agent_service(request).create_loop().boot() + task_service = loaded.task_service + if task_service is None: + return None + view = task_service.active_task_view(session_id) + if view is None: + return None + return { + "task_id": view["task_id"], + "status": view["status"], + "short_title": view["short_title"], + "description": view["description"], + "updated_at": view["updated_at"], + } + @app.get("/api/sessions/{session_id:path}") async def get_session(session_id: str, request: Request) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() @@ -326,9 +549,11 @@ def create_app( @app.delete("/api/sessions/{session_id:path}") async def delete_session(session_id: str, request: Request) -> dict[str, Any]: + if session_id.startswith("notify:"): + raise HTTPException(status_code=400, detail="Notification sessions cannot be archived") loaded = get_agent_service(request).create_loop().boot() - loaded.session_manager.end_session(session_id, "deleted") # type: ignore[union-attr] - return {"ok": True} + loaded.session_manager.end_session(session_id, "archived") # type: ignore[union-attr] + return {"ok": True, "archived": True} @app.get("/api/agents") async def list_agents(request: Request) -> list[dict[str, Any]]: @@ -363,6 +588,169 @@ def create_app( raise HTTPException(status_code=404, detail=str(exc)) from exc return _registered_agent_to_ui(agent) + @app.get("/api/authz/status") + async def get_authz_status(request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + config = loaded.config + registered = bool(config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret) + permissions: dict[str, Any] = {} + error = None + if config.authz.enabled and config.authz.base_url and config.backend_identity.backend_id: + try: + from beaver.integrations.authz import AuthzClient + + permissions = await AuthzClient( + config.authz.base_url, + timeout_seconds=config.authz.request_timeout_seconds, + ).get_permissions(config.backend_identity.backend_id) + except Exception as exc: # noqa: BLE001 - status endpoint reports dependency errors + error = str(exc) + return { + "enabled": config.authz.enabled, + "base_url": config.authz.base_url, + "outlook_mcp_url": config.authz.outlook_mcp_url, + "local_backend": { + "backend_id": config.backend_identity.backend_id or None, + "client_id": config.backend_identity.client_id or None, + "name": config.backend_identity.name or None, + "public_base_url": config.backend_identity.public_base_url or None, + "registered": registered, + }, + "permissions": permissions, + "error": error, + } + + @app.get("/api/mcp/servers") + async def list_mcp_servers(request: Request) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + return [_mcp_server_view(server_id, cfg, loaded.mcp_report.get(server_id, {})) for server_id, cfg in loaded.config.tools.mcp_servers.items()] + + @app.post("/api/mcp/servers") + async def add_mcp_server(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + server_id = _clean_text(payload.get("id")) + if not server_id: + raise HTTPException(status_code=400, detail="Server id is required") + config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace) + raw = _read_config_json(config_path) + tools = _ensure_dict(raw, "tools") + servers = _ensure_dict(tools, "mcpServers") + servers[server_id] = _mcp_config_payload(payload, server_id) + _write_config_json(config_path, raw) + _reload_agent_config(get_agent_service(request), config_path) + loaded = get_agent_service(request).create_loop().boot() + cfg = loaded.config.tools.mcp_servers[server_id] + return _mcp_server_view(server_id, cfg, {}) + + @app.put("/api/mcp/servers/{server_id}") + async def update_mcp_server(server_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]: + if _clean_text(payload.get("id")) and _clean_text(payload.get("id")) != server_id: + raise HTTPException(status_code=400, detail="Path id must match body id") + payload = {**payload, "id": server_id} + return await add_mcp_server(request, payload) + + @app.delete("/api/mcp/servers/{server_id}") + async def delete_mcp_server(server_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace) + raw = _read_config_json(config_path) + servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers") + if server_id not in servers: + raise HTTPException(status_code=404, detail="MCP server not found") + servers.pop(server_id, None) + _write_config_json(config_path, raw) + _reload_agent_config(get_agent_service(request), config_path) + return {"ok": True, "id": server_id} + + @app.post("/api/mcp/servers/{server_id}/test") + async def test_mcp(server_id: str, request: Request) -> dict[str, Any]: + from beaver.integrations.mcp import test_mcp_server + + loaded = get_agent_service(request).create_loop().boot() + cfg = loaded.config.tools.mcp_servers.get(server_id) + if cfg is None: + raise HTTPException(status_code=404, detail="MCP server not found") + return await test_mcp_server( + server_id, + cfg, + authz_config=loaded.config.authz, + backend_identity=loaded.config.backend_identity, + ) + + @app.get("/api/mcp/tools") + async def list_mcp_tools(request: Request) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + registry = loaded.tool_registry + report: dict[str, Any] = {} + if getattr(loaded, "mcp_manager", None) is not None: + loaded.mcp_report = await loaded.mcp_manager.connect_all(registry) + report = dict(loaded.mcp_report or {}) + groups: dict[str, list[dict[str, Any]]] = {} + for spec in registry.list_specs(): + if not spec.name.startswith("mcp_"): + continue + metadata = dict(getattr(spec, "metadata", {}) or {}) + server_id = str(metadata.get("server_id") or "") + if not server_id: + remainder = spec.name[len("mcp_"):] + server_id, _, _public_name = remainder.partition("_") + public_name = str(metadata.get("original_tool_name") or spec.name) + groups.setdefault(server_id, []).append( + { + "server_id": server_id, + "tool_name": public_name, + "name": spec.name, + "description": spec.description, + "parameters": spec.input_schema, + "kind": metadata.get("kind") or "online", + "category": metadata.get("category") or "online", + } + ) + result: list[dict[str, Any]] = [] + for key, value in sorted(groups.items()): + cfg = loaded.config.tools.mcp_servers.get(key) + server_report = report.get(key, {}) + kind = cfg.kind if cfg is not None else (value[0].get("kind") if value else "online") + category = cfg.category if cfg is not None else (value[0].get("category") if value else kind) + result.append( + { + "server_id": key, + "server_name": cfg.display_name if cfg and cfg.display_name else key, + "transport": cfg.transport if cfg is not None else "mcp", + "kind": kind, + "category": category, + "status": server_report.get("status"), + "last_error": server_report.get("last_error"), + "tool_count": len(value), + "tools": sorted(value, key=lambda item: item["tool_name"]), + } + ) + return result + + @app.get("/api/tools/servers") + async def list_tool_servers(request: Request) -> list[dict[str, Any]]: + return await list_mcp_servers(request) + + @app.get("/api/tools") + async def list_tools(request: Request) -> dict[str, Any]: + servers = await list_mcp_servers(request) + tool_groups = await list_mcp_tools(request) + server_map = {server["id"]: server for server in servers} + grouped = {"local": [], "online": []} + for group in tool_groups: + server = server_map.get(group["server_id"], {}) + kind = str(server.get("kind") or "online") + item = { + **group, + "server_name": server.get("name") or group["server_id"], + "transport": server.get("transport"), + "kind": kind, + "category": server.get("category") or kind, + "status": server.get("status"), + } + grouped["local" if kind == "local" else "online"].append(item) + return {"servers": servers, "groups": grouped} + @app.get("/api/skills") async def list_skills(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() @@ -374,11 +762,118 @@ def create_app( "source": "builtin" if record.source == "builtin" else "workspace", "available": loaded.skills_loader._record_available(record), # type: ignore[union-attr] "path": str(record.path), + "version": record.version, + "status": record.status, + "source_kind": record.source_kind, + "tool_hints": list(record.tool_hints), + "provenance": ( + loaded.skill_spec_store.read_published_skill(record.name).version.provenance # type: ignore[union-attr] + if loaded.skill_spec_store.read_published_skill(record.name) is not None # type: ignore[union-attr] + else {} + ), "agent_cards": [], } for record in skills ] + @app.get("/api/skills/{name}/download") + async def download_skill(name: str, request: Request) -> Response: + loaded = get_agent_service(request).create_loop().boot() + record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail="Skill not found") + skill_dir = record.path.parent + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for file_path in sorted(skill_dir.rglob("*")): + if file_path.is_file() and not file_path.is_symlink(): + archive.write(file_path, f"{name}/{file_path.relative_to(skill_dir)}") + return Response( + content=buffer.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{name}.zip"'}, + ) + + @app.delete("/api/skills/{name}") + async def delete_skill(name: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + target = loaded.workspace / "skills" / name + if not target.exists() or not target.is_dir(): + raise HTTPException(status_code=404, detail="Skill not found") + shutil.rmtree(target) + return {"ok": True, "name": name} + + @app.post("/api/skills/upload") + async def upload_skill(request: Request, file: UploadFile = File(...)) -> dict[str, Any]: + filename = file.filename or "" + if not filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="File must be a .zip archive") + loaded = get_agent_service(request).create_loop().boot() + try: + content = await file.read() + draft = _create_skill_upload_draft(loaded, filename, content) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return draft + + @app.post("/api/skills/migrate") + async def migrate_skills(request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + return SkillMigrationService(loaded.skill_spec_store).migrate_all() # type: ignore[arg-type] + + @app.get("/api/skills/migration-manifest") + async def get_skill_migration_manifest(request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + path = loaded.workspace / "skill_migration_manifest.json" + if not path.exists(): + return {"included": [], "skipped": []} + return json.loads(path.read_text(encoding="utf-8")) + + @app.get("/api/marketplaces/skills/search") + async def search_skillhub( + request: Request, + q: str = "", + sort: str = "relevance", + page: int = 0, + size: int = 12, + namespace: str | None = None, + ) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.search(q=q, sort=sort, page=page, size=size, namespace=namespace) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + @app.get("/api/marketplaces/skills/{namespace}/{slug}") + async def get_skillhub_detail(namespace: str, slug: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.detail(namespace, slug) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}") + async def get_skillhub_version(namespace: str, slug: str, version: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.version(namespace, slug, version) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + @app.post("/api/marketplaces/skills/{namespace}/{slug}/install") + async def install_skillhub_skill(namespace: str, slug: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.install(namespace, slug, version=_clean_text((payload or {}).get("version"))) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + @app.get("/api/skills/candidates") async def list_skill_candidates(request: Request, status: str | None = None) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() @@ -514,7 +1009,7 @@ def create_app( notes=str((payload or {}).get("notes") or ""), ) except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + raise _skill_draft_http_error(exc) from exc return review.to_dict() @app.post("/api/skills/{skill_name}/drafts/{draft_id}/approve") @@ -528,7 +1023,7 @@ def create_app( notes=str((payload or {}).get("notes") or ""), ) except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + raise _skill_draft_http_error(exc) from exc return review.to_dict() @app.post("/api/skills/{skill_name}/drafts/{draft_id}/reject") @@ -542,7 +1037,7 @@ def create_app( notes=str((payload or {}).get("notes") or ""), ) except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + raise _skill_draft_http_error(exc) from exc return review.to_dict() @app.post("/api/skills/{skill_name}/drafts/{draft_id}/publish") @@ -590,6 +1085,123 @@ def create_app( raise HTTPException(status_code=400, detail=str(exc)) from exc return spec.to_dict() + @app.get("/api/notifications") + async def list_notifications(request: Request) -> list[dict[str, Any]]: + cron_service = get_cron_service(request) + return [ + _notification_summary(job, run) + for job, run in cron_service.list_runs() + if run.mode == "notification" or run.notification_session_id == NOTIFICATION_SESSION_ID + ] + + @app.get("/api/notifications/{scheduled_run_id}") + async def get_notification(scheduled_run_id: str, request: Request) -> dict[str, Any]: + cron_service = get_cron_service(request) + found = cron_service.get_run(scheduled_run_id) + if found is None: + raise HTTPException(status_code=404, detail="Notification not found") + job, run = found + loaded = get_agent_service(request).create_loop().boot() + session_id = run.notification_session_id or NOTIFICATION_SESSION_ID + session = loaded.session_manager.get_or_create(session_id, source="notification", title="通知") # type: ignore[union-attr] + return { + **_notification_summary(job, run), + "detail": _session_detail(loaded.session_manager, session_id, session), # type: ignore[arg-type] + } + + @app.post("/api/notifications/{scheduled_run_id}/engage") + async def engage_notification(scheduled_run_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]: + cron_service = get_cron_service(request) + found = cron_service.get_run(scheduled_run_id) + if found is None: + raise HTTPException(status_code=404, detail="Notification not found") + job, run = found + intent = _scheduled_reply_intent((payload or {}).get("intent")) + task = get_agent_service(request).engage_scheduled_run(job=job, run=run, intent=intent) + cron_service.mark_run_engaged(scheduled_run_id, task_id=task.task_id, intent=intent) + return {"ok": True, "task_id": task.task_id, "intent": intent} + + @app.get("/api/cron/jobs") + async def list_cron_jobs(request: Request, include_disabled: bool = True) -> list[dict[str, Any]]: + cron_service = get_cron_service(request) + return [job.to_api_dict() for job in cron_service.list_jobs(include_disabled=include_disabled)] + + @app.post("/api/cron/jobs") + async def add_cron_job(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + cron_service = get_cron_service(request) + try: + schedule = schedule_from_api(payload) + job = cron_service.add_job( + name=str(payload.get("name") or "").strip(), + message=str(payload.get("message") or "").strip(), + schedule=schedule, + session_key=str(payload.get("session_key") or "").strip() or None, + payload_kind=str(payload.get("payload_kind") or "agent_turn"), + mode=str(payload.get("mode") or "notification").strip().lower(), + requires_followup=bool(payload.get("requires_followup", False)), + deliver=bool(payload.get("deliver", False)), + channel=str(payload.get("channel") or "").strip() or None, + to=str(payload.get("to") or "").strip() or None, + delete_after_run=bool(payload.get("delete_after_run", False)), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return job.to_api_dict() + + @app.delete("/api/cron/jobs/{job_id}") + async def delete_cron_job(job_id: str, request: Request) -> dict[str, Any]: + if not get_cron_service(request).remove_job(job_id): + raise HTTPException(status_code=404, detail="Cron job not found") + return {"ok": True, "id": job_id} + + @app.put("/api/cron/jobs/{job_id}/toggle") + async def toggle_cron_job(job_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]: + job = get_cron_service(request).update_enabled(job_id, bool(payload.get("enabled", True))) + if job is None: + raise HTTPException(status_code=404, detail="Cron job not found") + return job.to_api_dict() + + @app.post("/api/cron/jobs/{job_id}/run") + async def run_cron_job(job_id: str, request: Request) -> dict[str, Any]: + cron_service = get_cron_service(request) + if not await cron_service.run_job(job_id, force=True): + raise HTTPException(status_code=404, detail="Cron job not found") + job = cron_service.get_job(job_id) + return {"ok": True, "id": job_id, "job": job.to_api_dict() if job is not None else None} + + @app.get("/api/tasks") + async def list_tasks(request: Request) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + task_service = loaded.task_service + if task_service is None: + return [] + return [task_service.to_api_dict(task) for task in task_service.list_tasks()] + + @app.get("/api/tasks/{task_id}") + async def get_task(task_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + task_service = loaded.task_service + if task_service is None: + raise HTTPException(status_code=404, detail="Task service is unavailable") + task = task_service.get_task(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return { + **task_service.to_api_dict(task), + "events": [event.to_dict() for event in task_service.list_events(task_id)], + "runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store), # type: ignore[arg-type] + } + + @app.delete("/api/tasks/{task_id}") + async def delete_task(task_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + task_service = loaded.task_service + if task_service is None: + raise HTTPException(status_code=404, detail="Task service is unavailable") + if not task_service.delete_task(task_id): + raise HTTPException(status_code=404, detail="Task not found") + return {"ok": True, "task_id": task_id} + @app.post( "/api/chat", response_model=WebChatResponse, @@ -605,28 +1217,69 @@ def create_app( if not message: raise HTTPException(status_code=400, detail="'message' is required") + reply_to_scheduled_run_id = _clean_text(payload.reply_to_scheduled_run_id) + if reply_to_scheduled_run_id: + cron_service = get_cron_service(request) + found = cron_service.get_run(reply_to_scheduled_run_id) + if found is None: + raise HTTPException(status_code=404, detail="Notification not found") + job, run = found + intent = _scheduled_reply_intent(payload.scheduled_reply_intent) + try: + reply_kwargs = { + "job": job, + "run": run, + "intent": intent, + } + if payload.thinking_enabled is not None: + reply_kwargs["thinking_enabled"] = payload.thinking_enabled + result = await agent_service.submit_scheduled_reply(message, **reply_kwargs) + cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent) + if intent == "update_future": + cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, message)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + return WebChatResponse( + session_id=result.session_id, + run_id=result.run_id, + output_text=result.output_text, + finish_reason=result.finish_reason, + tool_iterations=result.tool_iterations, + provider_name=result.provider_name, + model=result.model, + usage=result.usage, + task_id=result.task_id, + task_status=result.task_status, + validation_result=result.validation_result, + ) + fallback_target = _model_dump(payload.fallback_target) auxiliary_target = _model_dump(payload.auxiliary_target) embedding_target = _model_dump(payload.embedding_target) try: - result = await agent_service.submit_direct( - message, - session_id=payload.session_id, - source="web", - user_id=payload.user_id, - title=payload.title, - execution_context=payload.execution_context, - model=payload.model, - provider_name=payload.provider_name, - embedding_model=payload.embedding_model, - temperature=payload.temperature, - max_tokens=payload.max_tokens, - max_tool_iterations=payload.max_tool_iterations, - fallback_target=fallback_target, - auxiliary_target=auxiliary_target, - embedding_target=embedding_target, - ) + direct_kwargs = { + "session_id": payload.session_id, + "source": "web", + "user_id": payload.user_id, + "title": payload.title, + "execution_context": payload.execution_context, + "model": payload.model, + "provider_name": payload.provider_name, + "embedding_model": payload.embedding_model, + "temperature": payload.temperature, + "max_tokens": payload.max_tokens, + "max_tool_iterations": payload.max_tool_iterations, + "fallback_target": fallback_target, + "auxiliary_target": auxiliary_target, + "embedding_target": embedding_target, + } + if payload.thinking_enabled is not None: + direct_kwargs["thinking_enabled"] = payload.thinking_enabled + result = await agent_service.submit_direct(message, **direct_kwargs) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: @@ -653,6 +1306,114 @@ def create_app( validation_result=result.validation_result, ) + @app.websocket("/ws/{session_id:path}") + async def chat_websocket(websocket: WebSocket, session_id: str) -> None: + """WebSocket chat adapter. + + This is intentionally a thin Web entrypoint: it delegates to + AgentService.submit_direct() and returns the same run/task metadata as + the REST chat endpoint. + """ + + await websocket.accept() + agent_service = getattr(websocket.app.state, "agent_service", None) + if not isinstance(agent_service, AgentService): + await websocket.send_json({"type": "error", "error": "AgentService is not ready"}) + await websocket.close(code=1011) + return + + while True: + try: + payload = await websocket.receive_json() + except WebSocketDisconnect: + break + except ValueError: + await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"}) + continue + if not isinstance(payload, dict): + await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"}) + continue + + message_type = (_clean_text(payload.get("type")) or "").lower() + if message_type == "ping": + await websocket.send_json({"type": "pong"}) + continue + if message_type != "message": + await websocket.send_json( + { + "type": "error", + "error": f"Unsupported websocket message type: {message_type or ''}", + } + ) + continue + + content = _clean_text(payload.get("content")) + if not content: + await websocket.send_json({"type": "error", "error": "'content' is required"}) + continue + + await websocket.send_json({"type": "status", "status": "thinking"}) + try: + reply_to_scheduled_run_id = _clean_text(payload.get("reply_to_scheduled_run_id")) + if reply_to_scheduled_run_id: + cron_service = get_cron_service(websocket) + found = cron_service.get_run(reply_to_scheduled_run_id) + if found is None: + raise ValueError("Notification not found") + job, run = found + intent = _scheduled_reply_intent(payload.get("scheduled_reply_intent")) + reply_kwargs = { + "job": job, + "run": run, + "intent": intent, + } + websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled")) + if websocket_thinking_enabled is not None: + reply_kwargs["thinking_enabled"] = websocket_thinking_enabled + result = await agent_service.submit_scheduled_reply(content, **reply_kwargs) + cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent) + if intent == "update_future": + cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, content)) + else: + direct_kwargs = { + "session_id": session_id, + "source": "websocket", + "user_id": _clean_text(payload.get("user_id")) or None, + "title": _clean_text(payload.get("title")) or None, + "execution_context": _clean_text(payload.get("execution_context")) or None, + "model": _clean_text(payload.get("model")) or None, + "provider_name": _clean_text(payload.get("provider_name")) or None, + "embedding_model": _clean_text(payload.get("embedding_model")) or None, + } + websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled")) + if websocket_thinking_enabled is not None: + direct_kwargs["thinking_enabled"] = websocket_thinking_enabled + result = await agent_service.submit_direct(content, **direct_kwargs) + except Exception as exc: + await websocket.send_json( + { + "type": "message", + "role": "assistant", + "content": f"Run failed before completion: {exc}", + "session_id": session_id, + "finish_reason": "error", + "metadata": { + "error": str(exc), + "input_metadata": _websocket_input_metadata(payload), + }, + } + ) + continue + + await websocket.send_json(_websocket_message_payload(result, input_payload=payload)) + await websocket.send_json( + { + "type": "session_updated", + "session_id": result.session_id, + "source": "websocket", + } + ) + @app.post( "/api/chat/feedback", response_model=WebChatFeedbackResponse, @@ -697,6 +1458,10 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An "validation_status": event.get("validation_status"), "feedback_state": event.get("feedback_state"), "feedback_error": event.get("feedback_error"), + "message_type": event.get("message_type"), + "scheduled_job_id": event.get("scheduled_job_id"), + "scheduled_run_id": event.get("scheduled_run_id"), + "cron_job_name": event.get("cron_job_name"), } ) return { @@ -707,6 +1472,266 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An } +def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]: + try: + archive = zipfile.ZipFile(io.BytesIO(content), "r") + except zipfile.BadZipFile as exc: + raise ValueError("Invalid zip archive") from exc + with archive: + file_infos = [info for info in archive.infolist() if not info.is_dir()] + if not file_infos: + raise ValueError("Zip archive is empty") + skill_entries = [] + for info in file_infos: + parts = Path(info.filename.replace("\\", "/")).parts + if "__MACOSX" in parts or Path(info.filename).name == ".DS_Store": + continue + if info.filename.replace("\\", "/").startswith("/") or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"Unsafe archive entry: {info.filename}") + if parts[-1] == "SKILL.md": + if len(parts) not in (1, 2): + raise ValueError("SKILL.md must be at root or inside one top-level directory") + skill_entries.append(info.filename) + if not skill_entries: + raise ValueError("Zip must contain SKILL.md") + skill_entry = skill_entries[0] + top = Path(skill_entry).parts[0] if len(Path(skill_entry).parts) == 2 else "" + raw_skill = archive.read(skill_entry).decode("utf-8", errors="replace") + frontmatter, body = parse_frontmatter(raw_skill) + skill_name = str(frontmatter.get("name") or top or Path(filename).stem).strip().replace(" ", "-") + if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}: + raise ValueError("Could not determine a safe skill name") + files: list[tuple[str, bytes]] = [] + for info in file_infos: + raw = info.filename.replace("\\", "/") + parts = Path(raw).parts + if "__MACOSX" in parts or Path(raw).name == ".DS_Store": + continue + if raw.startswith("/"): + raise ValueError(f"Unsafe archive entry: {info.filename}") + if top and parts and parts[0] != top: + raise ValueError("Zip archive must contain a single top-level skill directory") + rel_parts = parts[1:] if top and parts and parts[0] == top else parts + if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts): + raise ValueError(f"Unsafe archive entry: {info.filename}") + files.append(("/".join(rel_parts), archive.read(info))) + draft = loaded.draft_service.create_new_skill_draft( + skill_name=skill_name, + proposed_content=body, + proposed_frontmatter={ + **dict(frontmatter), + "name": skill_name, + "description": frontmatter.get("description") or skill_name, + }, + created_by="web-upload", + reason=f"Uploaded {filename}", + evidence_refs=[{"kind": "upload", "filename": filename, "files": sorted(path for path, _ in files)}], + ) + upload_dir = loaded.skill_spec_store.root / skill_name / "draft_uploads" / draft.draft_id + upload_dir.mkdir(parents=True, exist_ok=True) + for rel_path, file_bytes in files: + if rel_path == "SKILL.md": + continue + target = upload_dir / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(file_bytes) + draft.evidence_refs = [ + { + "kind": "upload", + "filename": filename, + "files": sorted(path for path, _ in files), + "supporting_upload_dir": str(upload_dir), + } + ] + loaded.skill_spec_store.write_draft(draft) + return draft.to_dict() + + +def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[str, Any]]: + grouped: dict[str, list[Any]] = {} + run_order: list[str] = [] + for record in session_manager.get_event_records(session_id): + if not record.run_id: + continue + if record.run_id not in grouped: + grouped[record.run_id] = [] + run_order.append(record.run_id) + grouped[record.run_id].append(record) + + runs: list[dict[str, Any]] = [] + for run_id in run_order: + records = grouped[run_id] + started = next((item for item in records if item.event_type == "run_started"), None) + completed = next( + (item for item in reversed(records) if item.event_type in {"run_completed", "run_failed"}), + None, + ) + user_event = next((item for item in records if item.event_type == "user_message_added"), None) + task_id = None + attempt_index = None + task_mode = None + source = None + title = None + if started is not None and isinstance(started.event_payload, dict): + task_id = started.event_payload.get("task_id") + attempt_index = started.event_payload.get("attempt_index") + task_mode = started.event_payload.get("task_mode") + source = started.event_payload.get("source") + if started is not None: + title = getattr(started, "title", None) + if title is None: + title = source or "run" + runs.append( + { + "run_id": run_id, + "session_id": session_id, + "title": title, + "source": source, + "task_id": task_id, + "attempt_index": attempt_index, + "task_mode": task_mode, + "user_input": user_event.content if user_event is not None else "", + "started_at": _iso_from_timestamp(started.timestamp if started is not None else None), + "ended_at": _iso_from_timestamp(completed.timestamp) if completed is not None else None, + "finish_reason": completed.finish_reason if completed is not None else None, + "events": [_debug_event_to_dict(item) for item in records], + } + ) + return runs + + +def _debug_event_to_dict(record: Any) -> dict[str, Any]: + return { + "message_id": record.message_id, + "run_id": record.run_id, + "role": record.role, + "event_type": record.event_type, + "content": record.content, + "timestamp": _iso_from_timestamp(record.timestamp), + "context_visible": record.context_visible, + "tool_name": record.tool_name, + "tool_call_id": record.tool_call_id, + "tool_calls": record.tool_calls, + "finish_reason": record.finish_reason, + "reasoning": record.reasoning, + "reasoning_details": record.reasoning_details, + "codex_reasoning_items": record.codex_reasoning_items, + "event_payload": record.event_payload, + } + + +def _notification_summary(job: Any, run: CronRunRecord) -> dict[str, Any]: + return { + "scheduled_run_id": run.scheduled_run_id, + "job_id": job.id, + "job_name": job.name, + "title": job.name, + "message": job.payload.message, + "status": run.status, + "mode": run.mode, + "started_at_ms": run.started_at_ms, + "finished_at_ms": run.finished_at_ms, + "started_at": _iso_from_ms(run.started_at_ms), + "finished_at": _iso_from_ms(run.finished_at_ms), + "output": run.output, + "error": run.error, + "notification_session_id": run.notification_session_id or NOTIFICATION_SESSION_ID, + "task_id": run.task_id, + "run_id": run.run_id, + "engaged": run.engaged, + "engaged_at_ms": run.engaged_at_ms, + "engage_intent": run.engage_intent, + } + + +def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memory_store: Any) -> list[dict[str, Any]]: + run_records = {record.run_id: record for record in run_memory_store.list_runs()} + labels = _agent_labels_for_task_events(events) + views: list[dict[str, Any]] = [] + seen: set[str] = set() + for index, run_id in enumerate(task.run_ids): + if run_id in seen: + continue + seen.add(run_id) + run_record = run_records.get(run_id) + session_id = run_record.session_id if run_record is not None else task.session_id + messages = [] + for record in session_manager.get_run_event_records(session_id, run_id): + if record.role not in {"user", "assistant", "tool"}: + continue + content = (record.content or "").strip() + if not content: + continue + messages.append( + { + "role": record.role, + "content": content, + "created_at": _iso_from_timestamp(record.timestamp), + "tool_name": record.tool_name, + } + ) + validation = run_record.validation_result if run_record is not None else None + views.append( + { + "run_id": run_id, + "title": labels.get(run_id) or ("主 Agent" if index == len(task.run_ids) - 1 else f"Agent {index + 1}"), + "session_id": session_id, + "started_at": run_record.started_at if run_record is not None else None, + "ended_at": run_record.ended_at if run_record is not None else None, + "success": run_record.success if run_record is not None else None, + "finish_reason": run_record.finish_reason if run_record is not None else None, + "attempt_index": run_record.attempt_index if run_record is not None else None, + "task_text": run_record.task_text if run_record is not None else "", + "messages": messages, + "validation_result": validation, + } + ) + return views + + +def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]: + labels: dict[str, str] = {} + for event in events: + payload = dict(getattr(event, "payload", {}) or {}) + for item in payload.get("node_results") or []: + if not isinstance(item, dict): + continue + run_id = str(item.get("run_id") or "") + node_id = str(item.get("node_id") or "").strip() + if run_id and node_id: + labels[run_id] = node_id + main_run_id = str(payload.get("main_run_id") or "") + if main_run_id: + labels[main_run_id] = "主 Agent" + return labels + + +def _scheduled_reply_intent(value: Any) -> str: + cleaned = str(value or "").strip().lower() + if cleaned == "update_future": + return "update_future" + if cleaned == "continue_task": + return "continue_task" + return "revise_once" + + +def _updated_scheduled_instruction(current: str, request: str) -> str: + cleaned_current = " ".join((current or "").strip().split()) + cleaned_request = " ".join((request or "").strip().split()) + if not cleaned_request: + return cleaned_current + return f"{cleaned_current}\n\n以后执行时请遵循:{cleaned_request}" + + +def _iso_from_ms(value: Any) -> str | None: + if value in (None, ""): + return None + try: + return _iso_from_timestamp(float(value) / 1000) + except (TypeError, ValueError): + return None + + def _iso_from_timestamp(value: Any) -> str: from datetime import datetime, timezone @@ -774,6 +1799,83 @@ def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]: } +def _mcp_server_view(server_id: str, cfg: Any, report: dict[str, Any]) -> dict[str, Any]: + transport = "stdio" if getattr(cfg, "command", "") else "http" + tool_names = list(report.get("tool_names") or []) + return { + "id": server_id, + "name": getattr(cfg, "display_name", "") or server_id, + "transport": transport, + "kind": getattr(cfg, "kind", "local" if transport == "stdio" else "online"), + "category": getattr(cfg, "category", "local" if transport == "stdio" else "online"), + "managed": bool(getattr(cfg, "managed", False)), + "source": getattr(cfg, "source", "config"), + "url": getattr(cfg, "url", "") or None, + "command": getattr(cfg, "command", "") or None, + "args": list(getattr(cfg, "args", []) or []), + "env": _redact_mapping(dict(getattr(cfg, "env", {}) or {})), + "headers": _redact_mapping(dict(getattr(cfg, "headers", {}) or {})), + "auth_mode": getattr(cfg, "auth_mode", "none") or "none", + "auth_audience": getattr(cfg, "auth_audience", "") or None, + "auth_scopes": list(getattr(cfg, "auth_scopes", []) or []), + "enabled": True, + "tool_timeout": getattr(cfg, "tool_timeout", 30), + "tool_count": int(report.get("tool_count") or len(tool_names)), + "tool_names": tool_names, + "status": report.get("status") or "disconnected", + "last_error": report.get("last_error"), + "sensitive": bool(getattr(cfg, "sensitive", False)), + } + + +def _mcp_config_payload(payload: dict[str, Any], server_id: str) -> dict[str, Any]: + command = _clean_text(payload.get("command")) or "" + url = _clean_text(payload.get("url")) or "" + auth_mode = (_clean_text(payload.get("auth_mode") or payload.get("authMode")) or "none").lower() + auth_audience = _clean_text(payload.get("auth_audience") or payload.get("authAudience")) or "" + if auth_mode == "oauth_backend_token" and not auth_audience: + auth_audience = f"mcp:{server_id}" + return { + "command": command, + "args": _coerce_str_list(payload.get("args")), + "env": _coerce_str_dict(payload.get("env")), + "url": url, + "headers": _coerce_str_dict(payload.get("headers")), + "authMode": auth_mode, + "authAudience": auth_audience, + "authScopes": _coerce_str_list(payload.get("auth_scopes") or payload.get("authScopes")), + "toolTimeout": int(payload.get("tool_timeout") or payload.get("toolTimeout") or 30), + "sensitive": bool(payload.get("sensitive", False)), + "kind": _clean_text(payload.get("kind")) or ("local" if command else "online"), + "category": _clean_text(payload.get("category")) or ("local" if command else "online"), + "managed": bool(payload.get("managed", False)), + "displayName": _clean_text(payload.get("display_name") or payload.get("displayName")) or server_id, + "source": _clean_text(payload.get("source")) or "config", + } + + +def _coerce_str_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def _coerce_str_dict(value: Any) -> dict[str, str]: + if not isinstance(value, dict): + return {} + return {str(key): str(item) for key, item in value.items() if item is not None} + + +def _redact_mapping(value: dict[str, str]) -> dict[str, str]: + redacted = {} + for key, item in value.items(): + if any(token in key.lower() for token in ("key", "token", "secret", "authorization")): + redacted[key] = _mask_secret(item) + else: + redacted[key] = item + return redacted + + def _model_dump(value: Any) -> dict[str, Any] | None: """兼容 Pydantic v1/v2 的最小导出辅助。""" @@ -786,6 +1888,244 @@ def _model_dump(value: Any) -> dict[str, Any] | None: return dict(value) +def _validation_status(validation_result: dict[str, Any] | None) -> str: + if validation_result is None: + return "unknown" + return "passed" if validation_result.get("accepted") is True else "failed" + + +def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]: + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + result: dict[str, Any] = dict(metadata) + attachments = payload.get("attachments") + if isinstance(attachments, list): + result["attachments"] = attachments + return result + + +def _bool_or_none(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if value is None: + return None + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + +def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]: + validation_result = getattr(result, "validation_result", None) + task_id = getattr(result, "task_id", None) + task_status = getattr(result, "task_status", None) + return { + "type": "message", + "role": "assistant", + "content": getattr(result, "output_text", "") or "", + "session_id": getattr(result, "session_id", None), + "run_id": getattr(result, "run_id", None), + "finish_reason": getattr(result, "finish_reason", None), + "provider_name": getattr(result, "provider_name", None), + "model": getattr(result, "model", None), + "usage": dict(getattr(result, "usage", {}) or {}), + "task_id": task_id, + "task_status": task_status, + "validation_result": validation_result, + "validation_status": _validation_status(validation_result), + "metadata": { + "task_id": task_id, + "task_status": task_status, + "validation_result": validation_result, + "input_metadata": _websocket_input_metadata(input_payload), + }, + } + + +def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool: + if provider_cfg is None or provider_name == "custom": + return False + return any( + [ + _clean_text(getattr(provider_cfg, "api_key", None)), + _clean_text(getattr(provider_cfg, "api_base", None)), + bool(getattr(provider_cfg, "extra_headers", None)), + ] + ) + + +def _auth_file_path() -> Path: + raw = os.getenv("NANOBOT_AUTH_FILE") or os.getenv("BEAVER_AUTH_FILE") + if raw: + return Path(raw) + return Path.home() / ".beaver" / "web_auth_users.json" + + +def _load_auth_users(path: Path) -> dict[str, str]: + if not path.exists(): + raise HTTPException(status_code=500, detail=f"Auth file not found: {path}") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=500, detail=f"Invalid auth file: {path}") from exc + + users: dict[str, str] = {} + if isinstance(raw, dict): + entries = raw.get("users") or raw.get("accounts") + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, dict): + continue + username = _clean_text(entry.get("username")) + password = entry.get("password") + if username and isinstance(password, str): + users[username] = password + for key, value in raw.items(): + if key in {"users", "accounts"}: + continue + username = _clean_text(key) + if username and isinstance(value, str): + users[username] = value + if not users: + raise HTTPException(status_code=500, detail=f"No valid users found in auth file: {path}") + return users + + +def _issue_web_token(app: FastAPI, username: str) -> str: + token = secrets.token_urlsafe(32) + app.state.auth_tokens[token] = username + return token + + +def _handoff_ttl_seconds() -> int: + raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip() + try: + return max(15, int(raw)) + except ValueError: + return 90 + + +def _handoff_replay_window_seconds() -> int: + raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip() + try: + return max(1, int(raw)) + except ValueError: + return 15 + + +def _prune_handoff_codes(app: FastAPI) -> None: + now = time.time() + replay_window = _handoff_replay_window_seconds() + expired = [] + for code, payload in list(app.state.handoff_codes.items()): + expires_at = float(payload.get("expires_at") or 0) + consumed_at = payload.get("consumed_at") + if expires_at <= now: + expired.append(code) + elif consumed_at is not None and now - float(consumed_at) > replay_window: + expired.append(code) + for code in expired: + app.state.handoff_codes.pop(code, None) + + +def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]: + _prune_handoff_codes(app) + code = secrets.token_urlsafe(24) + expires_at = int(time.time()) + _handoff_ttl_seconds() + app.state.handoff_codes[code] = { + "username": username, + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + "consumed_at": None, + } + return code, expires_at + + +def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]: + if not code.strip(): + raise HTTPException(status_code=400, detail="Handoff code is required") + _prune_handoff_codes(app) + payload = app.state.handoff_codes.get(code) + if payload is None: + raise HTTPException(status_code=401, detail="Invalid or expired handoff code") + now = time.time() + expires_at = float(payload.get("expires_at") or 0) + if expires_at <= now: + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=410, detail="Handoff code expired") + consumed_at = payload.get("consumed_at") + if consumed_at is None: + payload["consumed_at"] = now + elif now - float(consumed_at) > _handoff_replay_window_seconds(): + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=410, detail="Handoff code already used") + username = str(payload.get("username") or "").strip() + access_token = str(payload.get("access_token") or "").strip() + if not username or not access_token: + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=401, detail="Invalid handoff payload") + return { + "access_token": access_token, + "refresh_token": str(payload.get("refresh_token") or ""), + "token_type": "bearer", + "user_id": username, + "username": username, + "role": "owner", + } + + +def _require_web_user(app: FastAPI, authorization: str | None) -> str: + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header") + prefix = "bearer " + if not authorization.lower().startswith(prefix): + raise HTTPException(status_code=401, detail="Invalid Authorization header") + token = authorization[len(prefix):].strip() + if not token: + raise HTTPException(status_code=401, detail="Invalid token") + username = app.state.auth_tokens.get(token) + if not username: + raise HTTPException(status_code=401, detail="Invalid or expired token") + return username + + +def _backend_connection_view(request: Request) -> dict[str, Any]: + public_base_url = ( + os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL") + or str(request.base_url).rstrip("/") + ) + backend_id = os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") + client_id = os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id + return { + "backend_id": backend_id, + "client_id": client_id, + "name": os.getenv("NANOBOT_BACKEND_IDENTITY__NAME") or backend_id, + "public_base_url": public_base_url, + "api_base_url": public_base_url, + "frontend_base_url": public_base_url, + "ws_base_url": public_base_url.replace("http://", "ws://").replace("https://", "wss://", 1), + "registered": bool(backend_id), + } + + +def _local_backend_view() -> dict[str, Any]: + return { + "backend_id": os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"), + "client_id": os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"), + "name": os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"), + "public_base_url": os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL"), + "authz": { + "enabled": os.getenv("NANOBOT_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"}, + "base_url": os.getenv("NANOBOT_AUTHZ__BASE_URL"), + }, + } + + def _clean_text(value: Any) -> str | None: if value is None: return None @@ -793,6 +2133,12 @@ def _clean_text(value: Any) -> str | None: return text or None +def _skill_draft_http_error(exc: ValueError) -> HTTPException: + detail = str(exc) + status_code = 404 if detail.startswith("Draft not found:") else 400 + return HTTPException(status_code=status_code, detail=detail) + + def _mask_secret(value: str | None) -> str: secret = _clean_text(value) if not secret: @@ -832,4 +2178,21 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None loop = getattr(agent_service, "_loop", None) loaded = getattr(loop, "loaded", None) if loop is not None else None if loaded is not None: + old_manager = getattr(loaded, "mcp_manager", None) + if old_manager is not None: + async def _close_old_manager() -> None: + await old_manager.close() + + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(_close_old_manager()) + else: + running_loop.create_task(_close_old_manager()) loaded.config = config + loaded.mcp_manager = MCPConnectionManager( + config.tools.mcp_servers, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + loaded.mcp_report = {} diff --git a/app-instance/backend/beaver/interfaces/web/schemas/chat.py b/app-instance/backend/beaver/interfaces/web/schemas/chat.py index accf837..7fc6ab7 100644 --- a/app-instance/backend/beaver/interfaces/web/schemas/chat.py +++ b/app-instance/backend/beaver/interfaces/web/schemas/chat.py @@ -60,10 +60,13 @@ class WebChatRequest(BaseModel): embedding_model: str | None = None temperature: float | None = None max_tokens: int | None = None + thinking_enabled: bool | None = None max_tool_iterations: int | None = None fallback_target: WebProviderTarget | None = None auxiliary_target: WebProviderTarget | None = None embedding_target: WebProviderTarget | None = None + reply_to_scheduled_run_id: str | None = None + scheduled_reply_intent: str | None = None class WebChatResponse(BaseModel): diff --git a/app-instance/backend/beaver/memory/runs/store.py b/app-instance/backend/beaver/memory/runs/store.py index cc0c6f8..f4b63bb 100644 --- a/app-instance/backend/beaver/memory/runs/store.py +++ b/app-instance/backend/beaver/memory/runs/store.py @@ -44,6 +44,29 @@ class RunMemoryStore: def append_skill_effect(self, effect: SkillEffectRecord) -> None: self._append_jsonl(self.effects_path, effect.to_dict()) + def update_skill_effects_for_run(self, run_id: str, **updates: object) -> list[SkillEffectRecord]: + effects = [SkillEffectRecord.from_dict(item) for item in self._read_jsonl(self.effects_path)] + updated: list[SkillEffectRecord] = [] + for index, effect in enumerate(effects): + if effect.run_id != run_id: + continue + payload = effect.to_dict() + payload.update(updates) + next_effect = SkillEffectRecord.from_dict(payload) + effects[index] = next_effect + updated.append(next_effect) + if not updated: + return [] + self.effects_path.parent.mkdir(parents=True, exist_ok=True) + self.effects_path.write_text( + "".join( + json.dumps(effect.to_dict(), ensure_ascii=False, sort_keys=True) + "\n" + for effect in effects + ), + encoding="utf-8", + ) + return updated + def list_runs(self) -> list[RunRecord]: return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)] diff --git a/app-instance/backend/beaver/services/__init__.py b/app-instance/backend/beaver/services/__init__.py index 1c77d6c..226917d 100644 --- a/app-instance/backend/beaver/services/__init__.py +++ b/app-instance/backend/beaver/services/__init__.py @@ -1,6 +1,6 @@ """Application services for Beaver.""" -__all__ = ["AgentService", "MemoryService"] +__all__ = ["AgentService", "CronService", "MemoryService"] def __getattr__(name: str): @@ -12,4 +12,8 @@ def __getattr__(name: str): from .memory_service import MemoryService return MemoryService + if name == "CronService": + from .cron_service import CronService + + return CronService raise AttributeError(name) diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py index b174c40..f9e2293 100644 --- a/app-instance/backend/beaver/services/agent_service.py +++ b/app-instance/backend/beaver/services/agent_service.py @@ -21,9 +21,13 @@ from beaver.coordinator.models import ExecutionNode, TeamRunResult from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader from beaver.engine.providers import make_provider_bundle from beaver.foundation.events import InboundMessage, OutboundMessage +from beaver.foundation.models import CronJob, CronRunRecord from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult +NOTIFICATION_SESSION_ID = "notify:default:scheduled" + + class AgentService: """面向 interfaces 的统一 agent 运行入口。 @@ -50,15 +54,24 @@ class AgentService: self._loop: AgentLoop | None = None self._run_task: asyncio.Task[None] | None = None self._main_agent_router = MainAgentRouter() + self._runtime_services: dict[str, Any] = {} def create_loop(self) -> AgentLoop: """创建并缓存当前 service 使用的 AgentLoop。""" if self._loop is None: self._loop = AgentLoop(profile=self.profile, loader=self.loader) + self._loop.runtime_services.update(self._runtime_services) self._loop.boot() return self._loop + def register_runtime_service(self, name: str, service: Any) -> None: + """Expose process-level services to tools during agent runs.""" + + self._runtime_services[name] = service + if self._loop is not None: + self._loop.runtime_services[name] = service + @property def has_loop(self) -> bool: """当前 service 是否已经创建过 loop。""" @@ -196,6 +209,191 @@ class AgentService: loop = self.create_loop() return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs) + async def run_scheduled_task( + self, + message: str, + *, + session_id: str, + cron_job_id: str, + cron_job_name: str, + scheduled_run_id: str | None = None, + requires_followup: bool = False, + ) -> AgentRunResult: + """Run a cron trigger as a normal internal Task. + + Scheduled jobs are product-level Tasks, not hidden one-off agent turns. + This entry bypasses the main-agent classifier and forces Task mode so + every trigger produces a TaskRecord, validation, feedback state, and a + run_id that the scheduled-task history can link to. + """ + + loaded = self.create_loop().boot() + task_service = self._require_loaded(loaded, "task_service") + loop = self.create_loop() + task = task_service.create_task( + session_id=session_id, + description=message, + creator="cron", + metadata={ + "source": "scheduled_cron", + "cron_job_id": cron_job_id, + "cron_job_name": cron_job_name, + "scheduled_run_id": scheduled_run_id, + "user_engaged": False, + "requires_followup": requires_followup, + }, + ) + execution_context = ( + "This turn was triggered automatically by a scheduled task.\n\n" + f"Cron Job ID: {cron_job_id}\n" + f"Cron Job Name: {cron_job_name}\n" + f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n" + "Run it as a normal Beaver Task. Do not ask the user for confirmation; " + "execute the task and report the concrete outcome." + ) + runner = loop.submit_direct if self.is_running else loop.process_direct + result = await self._run_task_mode( + message, + runner=runner, + task=task, + kwargs={ + "session_id": session_id, + "source": "cron", + "user_id": "cron", + "title": cron_job_name, + "execution_context": execution_context, + }, + ) + loaded = self.create_loop().boot() + session_manager = self._require_loaded(loaded, "session_manager") + session_manager.update_latest_assistant_event_payload( + result.session_id, + result.run_id, + { + "message_type": "scheduled_reply", + "scheduled_job_id": job.id, + "scheduled_run_id": run.scheduled_run_id, + "cron_job_name": job.name, + "mode": "notification", + }, + ) + return result + + async def run_scheduled_notification( + self, + message: str, + *, + session_id: str = NOTIFICATION_SESSION_ID, + cron_job_id: str, + cron_job_name: str, + scheduled_run_id: str, + ) -> AgentRunResult: + """Run a cron trigger as a notification result, not as an active Task.""" + + loop = self.create_loop() + loaded = loop.boot() + session_manager = self._require_loaded(loaded, "session_manager") + runner = loop.submit_direct if self.is_running else loop.process_direct + execution_context = ( + "This turn was triggered automatically by a scheduled notification.\n\n" + f"Cron Job ID: {cron_job_id}\n" + f"Cron Job Name: {cron_job_name}\n" + f"Scheduled Run ID: {scheduled_run_id}\n" + "Generate the notification content directly for the user. Do not ask for confirmation." + ) + result = await runner( + message, + session_id=session_id, + source="notification", + user_id="cron", + title=cron_job_name, + execution_context=execution_context, + ) + session_manager.update_latest_assistant_event_payload( + result.session_id, + result.run_id, + { + "message_type": "scheduled_result", + "scheduled_job_id": cron_job_id, + "scheduled_run_id": scheduled_run_id, + "cron_job_name": cron_job_name, + "mode": "notification", + }, + ) + return result + + def engage_scheduled_run( + self, + *, + job: CronJob, + run: CronRunRecord, + intent: str = "revise_once", + thinking_enabled: bool | None = None, + ) -> TaskRecord: + """Create or mark the Task that lets the user work on a scheduled result.""" + + loaded = self.create_loop().boot() + task_service = self._require_loaded(loaded, "task_service") + if run.task_id: + existing = task_service.get_task(run.task_id) + if existing is not None: + existing.metadata["user_engaged"] = True + existing.metadata["engage_intent"] = intent + task_service.store.upsert_task(existing) + return existing + + task = task_service.create_task( + session_id=run.notification_session_id or NOTIFICATION_SESSION_ID, + description=f"修改定时通知:{job.name}", + creator="cron", + metadata={ + "source": "scheduled_run", + "cron_job_id": job.id, + "cron_job_name": job.name, + "scheduled_run_id": run.scheduled_run_id, + "scheduled_output": run.output, + "user_engaged": True, + "engage_intent": intent, + }, + ) + return task + + async def submit_scheduled_reply( + self, + message: str, + *, + job: CronJob, + run: CronRunRecord, + intent: str = "revise_once", + ) -> AgentRunResult: + task = self.engage_scheduled_run(job=job, run=run, intent=intent) + loop = self.create_loop() + runner = loop.submit_direct if self.is_running else loop.process_direct + execution_context = ( + "The user is replying to a scheduled notification result.\n\n" + f"Cron Job ID: {job.id}\n" + f"Cron Job Name: {job.name}\n" + f"Scheduled Run ID: {run.scheduled_run_id}\n" + f"Engagement intent: {intent}\n" + f"Original scheduled instruction: {job.payload.message}\n" + f"Original notification output:\n{run.output or ''}\n\n" + "Handle this as a Task continuation. If the intent is update_future, explain the durable change " + "that should apply to future notifications." + ) + return await self._run_task_mode( + message, + runner=runner, + task=task, + kwargs={ + "session_id": task.session_id, + "source": "notification", + "user_id": "web", + "title": job.name, + "execution_context": execution_context, + "thinking_enabled": thinking_enabled, + }, + ) + async def submit_feedback( self, *, @@ -269,19 +467,51 @@ class AgentService: generated_candidates = [] validation = ValidationResult.from_dict(updated.validation_result) + if not already_recorded: + run_memory_store = self._require_loaded(loaded, "run_memory_store") + feedback_payload = { + "feedback_type": normalized, + "comment": comment or "", + "task_status": updated.status, + } + run_memory_store.update_run_record( + run_id, + success=normalized == "satisfied", + feedback=feedback_payload, + ) + run_memory_store.update_skill_effects_for_run( + run_id, + success=normalized == "satisfied", + feedback_score=self._feedback_score_for_learning(normalized, validation), + notes=(comment or normalized).strip(), + ) + skill_learning_service = self._require_loaded(loaded, "skill_learning_service") + skill_learning_service.rescore_skill_versions() if already_recorded: generated_candidates = [] elif normalized == "satisfied" and validation is not None and validation.accepted: - skill_learning_service = self._require_loaded(loaded, "skill_learning_service") - generated_candidates = [item.to_dict() for item in skill_learning_service.build_learning_candidates()] + generated_candidates = [ + item.to_dict() + for item in skill_learning_service.build_learning_candidates_for_task( + updated.task_id, + trigger_run_id=run_id, + ) + ] elif normalized == "abandon": - memory_service = self._require_loaded(loaded, "memory_service") - memory_service.get_store().add( - "memory", - ( - f"Failure memory: task {task.task_id} in session {session_id} was abandoned. " - f"Reason: {(comment or 'not specified').strip()}" - ), + session_manager.append_message( + session_id, + run_id=run_id, + role="system", + event_type="task_failure_evidence_recorded", + event_payload={ + "task_id": updated.task_id, + "feedback_type": normalized, + "comment": comment or "", + "task_status": updated.status, + "durable_memory_written": False, + }, + content=(comment or "Task abandoned; retained as run/session failure evidence."), + context_visible=False, ) return { @@ -302,20 +532,46 @@ class AgentService: ) -> AgentRunResult: loaded = self.create_loop().boot() task_service = self._require_loaded(loaded, "task_service") + session_manager = self._require_loaded(loaded, "session_manager") session_id = kwargs.get("session_id") or uuid4().hex kwargs = dict(kwargs) kwargs["session_id"] = session_id + provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs) + kwargs["provider_bundle"] = provider_bundle + router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider + router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime active_task = task_service.get_latest_open_task(session_id) - decision = self._main_agent_router.classify(message, active_task=active_task) + decision = await self._main_agent_router.classify( + message, + active_task=active_task, + provider=router_provider, + model=getattr(router_runtime, "model", None), + recent_messages=session_manager.get_messages_as_conversation(session_id), + thinking_enabled=kwargs.get("thinking_enabled"), + ) + if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"): + active_task.metadata["short_title"] = decision.short_title + task_service.store.upsert_task(active_task) + if active_task is not None and decision.closes_task: + task_service.close_task(active_task.task_id, reason=decision.reason) + return await runner(message, **kwargs) + if active_task is not None and decision.abandons_task: + task_service.abandon_task(active_task.task_id, reason=decision.reason) + return await runner(message, **kwargs) if not decision.is_task: + kwargs["include_skill_assembly"] = False + kwargs["include_tools"] = False return await runner(message, **kwargs) task = ( task_service.create_task( session_id=session_id, description=message, - metadata={"router_reason": decision.reason}, + metadata={ + "router_reason": decision.reason, + **({"short_title": decision.short_title} if decision.short_title else {}), + }, ) if active_task is None or decision.starts_new_task else active_task @@ -420,7 +676,7 @@ class AgentService: "task_id": task.task_id, "task_mode": True, "attempt_index": attempt_index, - "learning_candidate_enabled": False, + "allow_candidate_generation": False, } ) if attempt_index == 2 and latest_validation is not None: @@ -433,6 +689,14 @@ class AgentService: ) elif team_execution_context: attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context) + attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context( + task=task, + user_message=message, + attempt_index=attempt_index, + latest_validation=latest_validation, + plan=plan, + team_summaries=team_summaries, + ) result = await runner(message, **attempt_kwargs) last_result = result @@ -519,7 +783,7 @@ class AgentService: parent_session_id=parent_session_id, parent_run_id=None, provider_bundle_factory=provider_bundle_factory, - learning_candidate_enabled=False, + allow_candidate_generation=False, ) return result, None except Exception as exc: @@ -542,6 +806,93 @@ class AgentService: return [receipt.skill_name for receipt in record.activated_skills] return [] + @staticmethod + def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float: + if feedback_type == "satisfied": + if validation is not None: + return max(0.0, min(1.0, float(validation.score))) + return 1.0 + if feedback_type == "revise": + return 0.5 + return 0.0 + + @staticmethod + def _build_skill_selection_context( + *, + task: TaskRecord, + user_message: str, + attempt_index: int, + latest_validation: ValidationResult | None = None, + plan: TaskExecutionPlan | None = None, + team_summaries: list[str] | None = None, + ) -> str: + phase = f"attempt_{attempt_index}" + if latest_validation is not None: + phase = f"revision_attempt_{attempt_index}" + elif plan is not None and plan.is_team: + phase = f"team_synthesis_attempt_{attempt_index}" + + sections = [ + f"Task goal:\n{task.goal or task.description}", + f"Task description:\n{task.description}", + f"Current user request:\n{user_message}", + f"Execution phase:\n{phase}", + f"Task status:\n{task.status}", + ] + if task.constraints: + sections.append("Known constraints:\n" + "\n".join(f"- {item}" for item in task.constraints)) + if task.skill_names: + sections.append( + "Previously activated skills (reuse bias, not pinned):\n" + + "\n".join(f"- {item}" for item in task.skill_names) + ) + else: + sections.append("Previously activated skills:\nNone") + if latest_validation is not None: + validation_lines = [ + f"accepted: {latest_validation.accepted}", + f"score: {latest_validation.score}", + ] + if latest_validation.issues: + validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues)) + if latest_validation.missing_requirements: + validation_lines.append( + "missing requirements:\n" + + "\n".join(f"- {item}" for item in latest_validation.missing_requirements) + ) + if latest_validation.recommended_revision_prompt: + validation_lines.append( + "recommended revision:\n" + + latest_validation.recommended_revision_prompt + ) + sections.append("Validation feedback:\n" + "\n".join(validation_lines)) + if plan is not None: + plan_lines = [ + f"mode: {plan.mode}", + f"reason: {plan.reason}", + ] + if plan.final_synthesis_instruction: + plan_lines.append(f"final synthesis instruction: {plan.final_synthesis_instruction}") + if plan.graph is not None: + plan_lines.append(f"strategy: {plan.graph.strategy}") + plan_lines.append( + "nodes:\n" + + "\n".join( + f"- {node.node_id}: {node.task}" + for node in plan.graph.nodes + ) + ) + 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. " + "Select new skills only if the current request, revision, or execution plan needs a different capability. " + "If no published skill matches, return [] and let the run continue without skills." + ) + return "\n\n".join(section for section in sections if section.strip()) + @staticmethod def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str: lines = [] @@ -611,8 +962,8 @@ class AgentService: skill.name for skill in node.inherited_pinned_skill_contexts ] payload["skill_query"] = node.agent.metadata.get("skill_query") - payload["generated_skill_draft_id"] = node.agent.metadata.get("generated_skill_draft_id") - payload["generated_skill_name"] = node.agent.metadata.get("generated_skill_name") + 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 diff --git a/app-instance/backend/beaver/services/cron_service.py b/app-instance/backend/beaver/services/cron_service.py new file mode 100644 index 0000000..d723697 --- /dev/null +++ b/app-instance/backend/beaver/services/cron_service.py @@ -0,0 +1,508 @@ +"""Cron scheduling service for Beaver scheduled Tasks.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import re +import tempfile +import threading +import time +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from beaver.foundation.models import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule + +try: # pragma: no cover - exercised through cron schedule tests when installed + from croniter import croniter +except ModuleNotFoundError: # pragma: no cover - defensive dependency guard + croniter = None # type: ignore[assignment] + + +CronCallback = Callable[..., Awaitable[CronExecutionResult | str | None]] + +_DURATION_RE = re.compile( + r"^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$", + re.IGNORECASE, +) +_CRON_FIELD_RE = re.compile(r"^[\d\*\?,\-/LW#]+$", re.IGNORECASE) +_MAX_HISTORY = 20 + + +class CronService: + """Persistent single-timer scheduler. + + Hermes' cron implementation stores jobs as JSON and ticks safely in the + background. Beaver keeps that shape, but the callback is required to route + agent work through Task mode so every scheduled trigger is visible as a + normal Task. + """ + + def __init__(self, store_path: str | Path, *, on_job: CronCallback | None = None) -> None: + self.store_path = Path(store_path) + self.on_job = on_job + self._jobs: list[CronJob] | None = None + self._lock = threading.Lock() + self._running = False + self._timer_task: asyncio.Task[None] | None = None + + async def start(self) -> None: + self._running = True + self._load_jobs() + self._recompute_next_runs() + self._save_jobs() + self._arm_timer() + + def stop(self) -> None: + self._running = False + if self._timer_task is not None: + self._timer_task.cancel() + self._timer_task = None + + def status(self) -> dict[str, Any]: + jobs = self.list_jobs(include_disabled=True) + return { + "enabled": self._running, + "jobs": len(jobs), + "next_wake_at_ms": self._next_wake_ms(), + } + + def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: + jobs = list(self._load_jobs()) + if not include_disabled: + jobs = [job for job in jobs if job.enabled] + return sorted(jobs, key=lambda job: job.next_run_at_ms or 9_999_999_999_999) + + def get_job(self, job_id: str) -> CronJob | None: + for job in self._load_jobs(): + if job.id == job_id: + return job + return None + + def add_job( + self, + *, + name: str, + message: str, + schedule: CronSchedule, + session_key: str | None = None, + payload_kind: str = "agent_turn", + mode: str = "notification", + requires_followup: bool = False, + deliver: bool = False, + channel: str | None = None, + to: str | None = None, + delete_after_run: bool = False, + ) -> CronJob: + cleaned_name = name.strip() or message[:50].strip() or "scheduled task" + cleaned_message = message.strip() + if not cleaned_message: + raise ValueError("message is required") + validate_schedule(schedule) + now = _now_ms() + job = CronJob( + id=uuid4().hex[:12], + name=cleaned_name, + enabled=True, + schedule=schedule, + payload=CronPayload( + kind=payload_kind if payload_kind in {"agent_turn", "system_event"} else "agent_turn", # type: ignore[arg-type] + mode="task" if mode == "task" else "notification", + message=cleaned_message, + session_key=session_key, + requires_followup=requires_followup, + deliver=deliver, + channel=channel, + to=to, + ), + next_run_at_ms=compute_next_run(schedule, now_ms=now), + created_at_ms=now, + updated_at_ms=now, + delete_after_run=delete_after_run, + ) + with self._lock: + jobs = self._load_jobs_unlocked() + jobs.append(job) + self._jobs = jobs + self._save_jobs_unlocked() + self._arm_timer() + return job + + def update_enabled(self, job_id: str, enabled: bool) -> CronJob | None: + with self._lock: + jobs = self._load_jobs_unlocked() + for job in jobs: + if job.id != job_id: + continue + job.enabled = bool(enabled) + job.updated_at_ms = _now_ms() + job.next_run_at_ms = compute_next_run(job.schedule) if job.enabled else None + self._save_jobs_unlocked() + self._arm_timer() + return job + return None + + def remove_job(self, job_id: str) -> bool: + with self._lock: + jobs = self._load_jobs_unlocked() + next_jobs = [job for job in jobs if job.id != job_id] + if len(next_jobs) == len(jobs): + return False + self._jobs = next_jobs + self._save_jobs_unlocked() + self._arm_timer() + return True + + async def run_job(self, job_id: str, *, force: bool = False) -> bool: + job = self.get_job(job_id) + if job is None: + return False + if not force and not job.enabled: + return False + await self._execute_job(job) + self._save_jobs() + self._arm_timer() + return True + + def list_runs(self) -> list[tuple[CronJob, CronRunRecord]]: + runs: list[tuple[CronJob, CronRunRecord]] = [] + for job in self.list_jobs(include_disabled=True): + runs.extend((job, run) for run in job.history) + return sorted(runs, key=lambda item: item[1].started_at_ms, reverse=True) + + def get_run(self, scheduled_run_id: str) -> tuple[CronJob, CronRunRecord] | None: + for job, run in self.list_runs(): + if run.scheduled_run_id == scheduled_run_id: + return job, run + return None + + def mark_run_engaged( + self, + scheduled_run_id: str, + *, + task_id: str, + intent: str, + ) -> tuple[CronJob, CronRunRecord] | None: + with self._lock: + jobs = self._load_jobs_unlocked() + for job in jobs: + for run in job.history: + if run.scheduled_run_id != scheduled_run_id: + continue + run.engaged = True + run.engaged_at_ms = _now_ms() + run.engage_intent = intent + run.task_id = task_id + job.updated_at_ms = _now_ms() + self._save_jobs_unlocked() + return job, run + return None + + def update_job_message(self, job_id: str, message: str) -> CronJob | None: + cleaned = message.strip() + if not cleaned: + raise ValueError("message is required") + with self._lock: + jobs = self._load_jobs_unlocked() + for job in jobs: + if job.id != job_id: + continue + job.payload.message = cleaned + job.updated_at_ms = _now_ms() + self._save_jobs_unlocked() + return job + return None + + async def _on_timer(self) -> None: + now = _now_ms() + due_jobs = [ + job + for job in self.list_jobs(include_disabled=False) + if job.next_run_at_ms is not None and job.next_run_at_ms <= now + ] + for job in due_jobs: + await self._execute_job(job) + self._save_jobs() + self._arm_timer() + + async def _execute_job(self, job: CronJob) -> None: + start_ms = _now_ms() + run_record = CronRunRecord(started_at_ms=start_ms, mode=job.payload.mode) + try: + result = CronExecutionResult(mode=job.payload.mode) + if self.on_job is not None: + raw = await self._call_on_job(job, run_record) + result = raw if isinstance(raw, CronExecutionResult) else CronExecutionResult(response=raw, mode=job.payload.mode) + run_record.status = "ok" + run_record.mode = result.mode + run_record.output = result.response + run_record.notification_session_id = result.notification_session_id + run_record.task_id = result.task_id + run_record.run_id = result.run_id + job.last_status = "ok" + job.last_error = None + except Exception as exc: + run_record.status = "error" + run_record.error = str(exc) + job.last_status = "error" + job.last_error = str(exc) + finally: + finish_ms = _now_ms() + run_record.finished_at_ms = finish_ms + job.last_run_at_ms = start_ms + job.updated_at_ms = finish_ms + job.history.append(run_record) + job.history = job.history[-_MAX_HISTORY:] + + if job.schedule.kind == "at": + if job.delete_after_run: + with self._lock: + self._jobs = [item for item in self._load_jobs_unlocked() if item.id != job.id] + return + job.enabled = False + job.next_run_at_ms = None + return + + job.next_run_at_ms = compute_next_run(job.schedule, now_ms=_now_ms(), last_run_at_ms=job.last_run_at_ms) + + async def _call_on_job(self, job: CronJob, run_record: CronRunRecord) -> CronExecutionResult | str | None: + if self.on_job is None: + return None + try: + params = inspect.signature(self.on_job).parameters + except (TypeError, ValueError): + params = {} + if len(params) >= 2: + return await self.on_job(job, run_record) + return await self.on_job(job) + + def _recompute_next_runs(self) -> None: + now = _now_ms() + changed = False + for job in self._load_jobs(): + if not job.enabled: + continue + if job.next_run_at_ms is None or job.next_run_at_ms < now - 7_200_000: + job.next_run_at_ms = compute_next_run(job.schedule, now_ms=now, last_run_at_ms=job.last_run_at_ms) + changed = True + if changed: + self._save_jobs() + + def _next_wake_ms(self) -> int | None: + candidates = [ + job.next_run_at_ms + for job in self._load_jobs() + if job.enabled and job.next_run_at_ms is not None + ] + return min(candidates) if candidates else None + + def _arm_timer(self) -> None: + if self._timer_task is not None: + self._timer_task.cancel() + self._timer_task = None + if not self._running: + return + next_wake = self._next_wake_ms() + if next_wake is None: + return + + async def tick() -> None: + await asyncio.sleep(max(0, next_wake - _now_ms()) / 1000) + if self._running: + await self._on_timer() + + self._timer_task = asyncio.create_task(tick()) + + def _load_jobs(self) -> list[CronJob]: + with self._lock: + return list(self._load_jobs_unlocked()) + + def _load_jobs_unlocked(self) -> list[CronJob]: + if self._jobs is not None: + return self._jobs + self.store_path.parent.mkdir(parents=True, exist_ok=True) + _secure_dir(self.store_path.parent) + if not self.store_path.exists(): + self._jobs = [] + return self._jobs + payload = json.loads(self.store_path.read_text(encoding="utf-8")) + raw_jobs = payload.get("jobs") if isinstance(payload, dict) else [] + self._jobs = [CronJob.from_dict(item) for item in raw_jobs or [] if isinstance(item, dict)] + return self._jobs + + def _save_jobs(self) -> None: + with self._lock: + self._save_jobs_unlocked() + + def _save_jobs_unlocked(self) -> None: + if self._jobs is None: + return + self.store_path.parent.mkdir(parents=True, exist_ok=True) + _secure_dir(self.store_path.parent) + fd, tmp_name = tempfile.mkstemp(prefix=".jobs-", suffix=".json", dir=str(self.store_path.parent)) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump( + {"version": 1, "updated_at_ms": _now_ms(), "jobs": [job.to_dict() for job in self._jobs]}, + handle, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, self.store_path) + _secure_file(self.store_path) + finally: + if tmp_path.exists(): + tmp_path.unlink() + + +def parse_duration(value: str) -> int: + match = _DURATION_RE.match(value.strip()) + if not match: + raise ValueError("duration must look like 30s, 15m, 2h, or 1d") + amount = int(match.group(1)) + unit = match.group(2).lower()[0] + multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400} + return amount * multipliers[unit] + + +def parse_schedule(value: str) -> CronSchedule: + raw = value.strip() + lowered = raw.lower() + if lowered.startswith("every "): + seconds = parse_duration(raw[6:].strip()) + return CronSchedule(kind="every", every_ms=seconds * 1000, display=f"every {seconds}s") + + parts = raw.split() + if len(parts) in {5, 6} and all(_CRON_FIELD_RE.match(item) for item in parts[:5]): + schedule = CronSchedule(kind="cron", expr=raw, display=raw) + validate_schedule(schedule) + return schedule + + if "T" in raw or re.match(r"^\d{4}-\d{2}-\d{2}", raw): + dt = _parse_datetime(raw) + return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000), display=f"once at {dt:%Y-%m-%d %H:%M}") + + seconds = parse_duration(raw) + at_ms = _now_ms() + seconds * 1000 + return CronSchedule(kind="at", at_ms=at_ms, display=f"once in {raw}") + + +def schedule_from_api(payload: dict[str, Any]) -> CronSchedule: + if payload.get("schedule"): + return parse_schedule(str(payload["schedule"])) + if payload.get("every_seconds") not in (None, ""): + seconds = int(payload["every_seconds"]) + if seconds <= 0: + raise ValueError("every_seconds must be greater than 0") + return CronSchedule(kind="every", every_ms=seconds * 1000, display=f"every {seconds}s") + if payload.get("cron_expr"): + expr = str(payload["cron_expr"]).strip() + schedule = CronSchedule(kind="cron", expr=expr, tz=_optional_str(payload.get("tz")), display=expr) + validate_schedule(schedule) + return schedule + if payload.get("at_iso"): + dt = _parse_datetime(str(payload["at_iso"])) + return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000), display=f"once at {dt:%Y-%m-%d %H:%M}") + raise ValueError("one of schedule, every_seconds, cron_expr, or at_iso is required") + + +def validate_schedule(schedule: CronSchedule) -> None: + if schedule.kind == "every": + if not schedule.every_ms or schedule.every_ms <= 0: + raise ValueError("every schedule requires a positive every_ms") + return + if schedule.kind == "at": + if not schedule.at_ms: + raise ValueError("at schedule requires at_ms") + return + if schedule.kind == "cron": + if not schedule.expr: + raise ValueError("cron schedule requires expr") + if schedule.tz: + try: + ZoneInfo(schedule.tz) + except Exception as exc: + raise ValueError(f"unknown timezone: {schedule.tz}") from exc + if croniter is None: + raise ValueError("cron schedules require the croniter package") + try: + croniter(schedule.expr, _aware_now(schedule.tz)) + except Exception as exc: + raise ValueError(f"invalid cron expression: {schedule.expr}") from exc + return + raise ValueError(f"unknown schedule kind: {schedule.kind}") + + +def compute_next_run( + schedule: CronSchedule, + *, + now_ms: int | None = None, + last_run_at_ms: int | None = None, +) -> int | None: + now_ms = now_ms or _now_ms() + if schedule.kind == "at": + return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None + if schedule.kind == "every": + if not schedule.every_ms or schedule.every_ms <= 0: + return None + base = last_run_at_ms or now_ms + next_run = base + schedule.every_ms + while next_run <= now_ms: + next_run += schedule.every_ms + return next_run + if schedule.kind == "cron" and schedule.expr and croniter is not None: + base = datetime.fromtimestamp((last_run_at_ms or now_ms) / 1000, tz=_timezone(schedule.tz)) + return int(croniter(schedule.expr, base).get_next(datetime).timestamp() * 1000) + return None + + +def _parse_datetime(value: str) -> datetime: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + return dt.astimezone() + return dt + + +def _aware_now(tz_name: str | None = None) -> datetime: + return datetime.now(tz=_timezone(tz_name)) + + +def _timezone(tz_name: str | None = None) -> Any: + if tz_name: + return ZoneInfo(tz_name) + return datetime.now().astimezone().tzinfo + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _secure_dir(path: Path) -> None: + try: + os.chmod(path, 0o700) + except OSError: + pass + + +def _secure_file(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def _optional_str(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value).strip() or None diff --git a/app-instance/backend/beaver/services/hermes_migration.py b/app-instance/backend/beaver/services/hermes_migration.py new file mode 100644 index 0000000..53df602 --- /dev/null +++ b/app-instance/backend/beaver/services/hermes_migration.py @@ -0,0 +1,262 @@ +"""Import no-credential Hermes Agent skills into Beaver.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +import json +import re +import shutil +from pathlib import Path +from typing import Any + +from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter +from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion +from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content + + +HERMES_REPO_URL = "https://github.com/NousResearch/hermes-agent" + +_CREDENTIAL_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"\bapi[_ -]?key\b", + r"\boauth\b", + r"\bbearer\s+token\b", + r"\baccess[_ -]?token\b", + r"\bclient[_ -]?secret\b", + r"\bsecret\b", + r"\bcredential", + r"\bspotify\b", + r"\bdiscord\b", + r"\bfeishu\b", + r"\bhome\s*assistant\b", + r"\bfal\b", + r"\bopenrouter\b", + r"\bwandb\b", + ] +] + + +@dataclass(slots=True) +class HermesMigrationService: + store: SkillSpecStore + manifest_path: Path | None = None + included_tools: list[dict[str, Any]] = field(default_factory=list) + skipped_tools: list[dict[str, Any]] = field(default_factory=list) + + def migrate( + self, + repo_path: str | Path, + *, + include_optional: bool = True, + dry_run: bool = False, + ) -> dict[str, Any]: + repo = Path(repo_path) + if not repo.exists(): + raise ValueError(f"Hermes repository not found: {repo}") + skill_files = self._discover_skill_files(repo, include_optional=include_optional) + included: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for skill_file in skill_files: + result = self._migrate_skill(repo, skill_file, dry_run=dry_run) + if result["status"] in {"included", "unchanged"}: + included.append(result) + else: + skipped.append(result) + manifest = { + "source": "hermes-agent", + "repo_url": HERMES_REPO_URL, + "repo_path": str(repo), + "generated_at": datetime.now(timezone.utc).isoformat(), + "dry_run": dry_run, + "included": included, + "skipped": skipped, + "tools": self._tool_manifest(), + } + path = self.manifest_path or (self.store.workspace / "hermes_migration_manifest.json") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return manifest + + def _discover_skill_files(self, repo: Path, *, include_optional: bool) -> list[Path]: + roots = [repo / "skills"] + if include_optional: + roots.append(repo / "optional-skills") + files: list[Path] = [] + for root in roots: + if root.exists(): + files.extend(sorted(root.glob("**/SKILL.md"))) + return files + + def _migrate_skill(self, repo: Path, skill_file: Path, *, dry_run: bool) -> dict[str, Any]: + relative = skill_file.relative_to(repo) + content = skill_file.read_text(encoding="utf-8") + frontmatter, body = parse_frontmatter(content) + skill_name = _safe_skill_name(str(frontmatter.get("name") or skill_file.parent.name)) + if not skill_name: + return _skip(relative, "unsafe_skill_name") + credential_reason = _credential_reason(content) + if credential_reason: + return _skip(relative, credential_reason, skill_name=skill_name) + normalized = normalize_frontmatter( + { + **frontmatter, + "name": skill_name, + "description": frontmatter.get("description") or skill_name, + } + ) + rendered = _render_skill_content(normalized, body) + content_hash = canonical_hash(rendered) + existing = self.store.read_published_skill(skill_name) + existing_spec = self.store.get_skill_spec(skill_name) + if existing is not None and existing.version.content_hash == content_hash: + return { + "status": "unchanged", + "skill_name": skill_name, + "version": existing.version.version, + "path": str(relative), + "reason": "same_content_hash", + } + next_version = self._next_version(skill_name) + if dry_run: + return { + "status": "included", + "skill_name": skill_name, + "version": next_version, + "path": str(relative), + "dry_run": True, + } + now = datetime.now(timezone.utc).isoformat() + skill_version = SkillVersion( + skill_name=skill_name, + version=next_version, + content_hash=content_hash, + summary_hash=canonical_hash(strip_frontmatter(rendered).strip()), + created_at=now, + created_by="hermes_migration", + change_reason=f"Import Hermes skill {relative}", + parent_version=existing.version.version if existing is not None else None, + review_state="published", + frontmatter=normalized, + summary=summarize_skill_content(body), + tool_hints=self.store._extract_tool_hints(normalized), + provenance={ + "source": "hermes-agent", + "repo_url": HERMES_REPO_URL, + "repo_path": str(repo), + "relative_path": str(relative), + }, + ) + self.store.write_skill_version(skill_version, rendered) + self._copy_supporting_files(skill_file.parent, skill_name, next_version) + spec = existing_spec or SkillSpec( + name=skill_name, + display_name=skill_name, + description=str(normalized.get("description") or skill_name), + created_at=now, + updated_at=now, + current_version=next_version, + status="active", + tags=[], + owners=["hermes-agent"], + source_kind="hermes-agent", + lineage=[], + ) + spec.current_version = next_version + spec.updated_at = now + spec.status = "active" + spec.source_kind = "hermes-agent" + if "hermes-agent" not in spec.owners: + spec.owners.append("hermes-agent") + self.store.write_skill_spec(spec) + self.store.set_current_version(skill_name, next_version) + published = self.store.read_index("published") + if skill_name not in published: + published.append(skill_name) + self.store.update_index("published", published) + return { + "status": "included", + "skill_name": skill_name, + "version": next_version, + "path": str(relative), + } + + def _copy_supporting_files(self, source_dir: Path, skill_name: str, version: str) -> None: + target_root = self.store.root / skill_name / "versions" / version + for source in sorted(source_dir.rglob("*")): + if not source.is_file() or source.name == "SKILL.md" or source.is_symlink(): + continue + relative = source.relative_to(source_dir) + if any(part in {"", ".", ".."} for part in relative.parts): + continue + target = target_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + + def _next_version(self, skill_name: str) -> str: + versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")] + numbers = [int(item[1:]) for item in versions if item[1:].isdigit()] + return f"v{(max(numbers) if numbers else 0) + 1:04d}" + + def _tool_manifest(self) -> dict[str, list[dict[str, Any]]]: + included = self.included_tools or [ + {"name": "todo", "reason": "implemented_builtin_no_api"}, + {"name": "clarify", "reason": "implemented_builtin_no_api"}, + {"name": "delegate", "reason": "implemented_builtin_no_api"}, + {"name": "spawn", "reason": "implemented_builtin_no_api"}, + {"name": "skills_list", "reason": "implemented_builtin_no_api"}, + {"name": "skill_manage", "reason": "implemented_builtin_no_api"}, + {"name": "terminal", "reason": "implemented_builtin_no_api"}, + {"name": "process", "reason": "implemented_builtin_no_api"}, + {"name": "patch", "reason": "implemented_builtin_no_api"}, + {"name": "write_file", "reason": "implemented_builtin_no_api"}, + {"name": "web_fetch", "reason": "implemented_builtin_no_api"}, + {"name": "web_search", "reason": "implemented_builtin_no_api"}, + {"name": "execute_code", "reason": "implemented_builtin_no_api"}, + ] + skipped = self.skipped_tools or [ + {"name": "spotify", "reason": "requires_oauth"}, + {"name": "discord", "reason": "requires_external_token"}, + {"name": "feishu", "reason": "requires_external_token"}, + {"name": "home_assistant", "reason": "requires_external_service_credentials"}, + {"name": "fal_image_generation", "reason": "requires_api_key"}, + {"name": "remote_web_providers", "reason": "requires_api_key_or_oauth"}, + ] + return {"included": included, "skipped": skipped} + + +def _credential_reason(content: str) -> str | None: + for pattern in _CREDENTIAL_PATTERNS: + if pattern.search(content): + return "requires_external_credentials" + return None + + +def _safe_skill_name(value: str) -> str: + cleaned = value.strip().replace(" ", "-") + if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned: + return "" + if not re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned): + return "" + return cleaned + + +def _skip(relative: Path, reason: str, *, skill_name: str | None = None) -> dict[str, Any]: + result = {"status": "skipped", "path": str(relative), "reason": reason} + if skill_name: + result["skill_name"] = skill_name + return result + + +def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: + lines = ["---"] + for key, value in normalize_frontmatter(frontmatter).items(): + if isinstance(value, list): + lines.append(f"{key}:") + for item in value: + lines.append(f" - {item}") + else: + lines.append(f"{key}: {value}") + lines.extend(["---", "", body.strip()]) + return "\n".join(lines).rstrip() + "\n" diff --git a/app-instance/backend/beaver/services/process_service.py b/app-instance/backend/beaver/services/process_service.py index 7b145f8..4a4cbfc 100644 --- a/app-instance/backend/beaver/services/process_service.py +++ b/app-instance/backend/beaver/services/process_service.py @@ -16,6 +16,7 @@ class SessionProcessProjector: run_records = {record.run_id: record for record in self.run_memory_store.list_runs()} runs: dict[str, dict[str, Any]] = {} events: list[dict[str, Any]] = [] + artifacts: list[dict[str, Any]] = [] def add_event( *, @@ -84,7 +85,7 @@ class SessionProcessProjector: "node_ids": node_ids, "skill_queries": payload.get("skill_queries") or [], "selected_skill_names": payload.get("selected_skill_names") or [], - "generated_skill_draft_ids": payload.get("generated_skill_draft_ids") or [], + "ephemeral_guidance_ids": payload.get("ephemeral_guidance_ids") or [], "skill_resolution_report": payload.get("skill_resolution_report") or [], "fallback_error": payload.get("fallback_error"), } @@ -151,13 +152,42 @@ class SessionProcessProjector: "skill_query": item.get("skill_query"), "selected_skill_names": item.get("selected_skill_names") or [], "ephemeral_skill_names": item.get("ephemeral_skill_names") or [], - "generated_skill_draft_id": item.get("generated_skill_draft_id"), - "generated_skill_name": item.get("generated_skill_name"), + "ephemeral_guidance_id": item.get("ephemeral_guidance_id"), + "ephemeral_guidance_name": item.get("ephemeral_guidance_name"), "ephemeral_used": bool(item.get("ephemeral_used")), "finish_reason": item.get("finish_reason"), "error": item.get("error"), }, } + guidance_id = item.get("ephemeral_guidance_id") + if guidance_id: + guidance_name = str(item.get("ephemeral_guidance_name") or guidance_id) + artifacts.append( + { + "artifact_id": f"{node_run_id}:ephemeral-guidance:{guidance_id}", + "run_id": str(node_run_id), + "actor_type": "agent", + "actor_id": str(item.get("node_id") or "sub-agent"), + "actor_name": str(item.get("node_id") or "Sub-agent"), + "title": f"Ephemeral guidance: {guidance_name}", + "artifact_type": "markdown", + "content": ( + f"# Ephemeral guidance\n\n" + f"- Guidance: {guidance_name}\n" + f"- Guidance ID: {guidance_id}\n" + f"- Scope: current delegated sub-agent run only" + ), + "metadata": { + "task_id": task_id, + "attempt_index": attempt_index, + "node_id": item.get("node_id"), + "ephemeral_guidance_id": guidance_id, + "ephemeral_guidance_name": guidance_name, + "ephemeral_skill_names": item.get("ephemeral_skill_names") or [], + }, + "created_at": created_at, + } + ) add_event( event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}", run_id=str(node_run_id), @@ -231,7 +261,7 @@ class SessionProcessProjector: return { "runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""), "events": sorted(events, key=lambda item: item.get("created_at") or ""), - "artifacts": [], + "artifacts": sorted(artifacts, key=lambda item: item.get("created_at") or ""), "agents": [], } diff --git a/app-instance/backend/beaver/services/skill_migration.py b/app-instance/backend/beaver/services/skill_migration.py new file mode 100644 index 0000000..fdb27ce --- /dev/null +++ b/app-instance/backend/beaver/services/skill_migration.py @@ -0,0 +1,208 @@ +"""Import legacy and staged skills into the Beaver SkillSpecStore.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import io +import json +import re +import zipfile +from pathlib import Path +from typing import Any + +from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter +from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion +from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content + + +@dataclass(slots=True) +class SkillMigrationService: + store: SkillSpecStore + repo_root: Path | None = None + + def migrate_all(self) -> dict[str, Any]: + included: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for path in self._backend_old_skills(): + self._migrate_skill_file(path, "backend-old", included, skipped) + for path in self._staged_skills(): + self._migrate_skill_file(path, "stevenli-staged", included, skipped) + for path in self._skill_zips(): + self._migrate_zip(path, included, skipped) + manifest = { + "generated_at": _now(), + "workspace": str(self.store.workspace), + "included": included, + "skipped": skipped, + } + manifest_path = self.store.workspace / "skill_migration_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return manifest + + def _backend_old_skills(self) -> list[Path]: + root = self._repo_root() / "app-instance" / "backend-old" / "nanobot" / "skills" + if not root.exists(): + return [] + return sorted(root.glob("*/SKILL.md")) + + def _staged_skills(self) -> list[Path]: + root = self.store.workspace / "state" / "skill-reviews" + if not root.exists(): + return [] + return sorted(root.glob("*/staged/*/SKILL.md")) + + def _skill_zips(self) -> list[Path]: + root = self.store.workspace / "skills" + if not root.exists(): + return [] + return sorted(root.glob("*.zip")) + + def _repo_root(self) -> Path: + if self.repo_root is not None: + return self.repo_root + return Path(__file__).resolve().parents[4] + + def _migrate_skill_file(self, path: Path, source: str, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None: + try: + content = path.read_text(encoding="utf-8") + result = self._publish_content(content, source=source, source_path=str(path)) + included.append(result) + except Exception as exc: + skipped.append({"source": source, "source_path": str(path), "reason": str(exc)}) + + def _migrate_zip(self, path: Path, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None: + try: + with zipfile.ZipFile(io.BytesIO(path.read_bytes()), "r") as archive: + entries = [info for info in archive.infolist() if not info.is_dir()] + skill_entry = _find_skill_entry(entries) + content = archive.read(skill_entry).decode("utf-8", errors="replace") + result = self._publish_content(content, source="stevenli-zip", source_path=str(path)) + skill_name = result["skill_name"] + version = result["version"] + top = Path(skill_entry).parts[0] if len(Path(skill_entry).parts) == 2 else "" + for info in entries: + raw = info.filename.replace("\\", "/") + if raw == skill_entry or raw.startswith("/") or "__MACOSX" in Path(raw).parts: + continue + parts = Path(raw).parts + rel_parts = parts[1:] if top and parts and parts[0] == top else parts + if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts): + continue + target = self.store.root / skill_name / "versions" / version / "/".join(rel_parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(info)) + included.append(result) + except Exception as exc: + skipped.append({"source": "stevenli-zip", "source_path": str(path), "reason": str(exc)}) + + def _publish_content(self, content: str, *, source: str, source_path: str) -> dict[str, Any]: + frontmatter, body = parse_frontmatter(content) + skill_name = _safe_name(str(frontmatter.get("name") or Path(source_path).parent.name)) + if not skill_name: + raise ValueError("unsafe or missing skill name") + normalized = normalize_frontmatter( + { + **frontmatter, + "name": skill_name, + "description": frontmatter.get("description") or skill_name, + } + ) + rendered = _render_skill_content(normalized, body) + content_hash = canonical_hash(rendered) + existing = self.store.read_published_skill(skill_name) + if existing is not None and existing.version.content_hash == content_hash: + return { + "status": "unchanged", + "skill_name": skill_name, + "version": existing.version.version, + "source": source, + "source_path": source_path, + } + version_id = self._next_version(skill_name) + now = _now() + skill_version = SkillVersion( + skill_name=skill_name, + version=version_id, + content_hash=content_hash, + summary_hash=canonical_hash(strip_frontmatter(rendered).strip()), + created_at=now, + created_by="migration", + change_reason=f"Import skill from {source}", + parent_version=existing.version.version if existing is not None else None, + review_state="published", + frontmatter=normalized, + summary=summarize_skill_content(body), + tool_hints=self.store._extract_tool_hints(normalized), + provenance={"source": source, "source_path": source_path, "imported_at": now}, + ) + self.store.write_skill_version(skill_version, rendered) + spec = self.store.get_skill_spec(skill_name) or SkillSpec( + name=skill_name, + display_name=skill_name, + description=str(normalized.get("description") or skill_name), + created_at=now, + updated_at=now, + current_version=version_id, + status="active", + tags=[], + owners=["migration"], + source_kind=source, + lineage=[], + ) + spec.current_version = version_id + spec.updated_at = now + spec.status = "active" + spec.source_kind = source + if "migration" not in spec.owners: + spec.owners.append("migration") + self.store.write_skill_spec(spec) + self.store.set_current_version(skill_name, version_id) + published = self.store.read_index("published") + if skill_name not in published: + published.append(skill_name) + self.store.update_index("published", published) + return {"status": "included", "skill_name": skill_name, "version": version_id, "source": source, "source_path": source_path} + + def _next_version(self, skill_name: str) -> str: + versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")] + numbers = [int(item[1:]) for item in versions if item[1:].isdigit()] + return f"v{(max(numbers) if numbers else 0) + 1:04d}" + + +def _find_skill_entry(entries: list[zipfile.ZipInfo]) -> str: + candidates = [] + for info in entries: + raw = info.filename.replace("\\", "/") + parts = Path(raw).parts + if raw.startswith("/") or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"unsafe archive entry: {info.filename}") + if parts and parts[-1] == "SKILL.md" and len(parts) in (1, 2): + candidates.append(raw) + if not candidates: + raise ValueError("zip has no root SKILL.md") + return candidates[0] + + +def _safe_name(value: str) -> str: + cleaned = value.strip().replace(" ", "-") + if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned: + return "" + return cleaned if re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned) else "" + + +def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: + lines = ["---"] + for key, value in normalize_frontmatter(frontmatter).items(): + if isinstance(value, list): + lines.append(f"{key}:") + for item in value: + lines.append(f" - {item}") + else: + lines.append(f"{key}: {value}") + lines.extend(["---", "", body.strip()]) + return "\n".join(lines).rstrip() + "\n" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/app-instance/backend/beaver/services/skillhub_service.py b/app-instance/backend/beaver/services/skillhub_service.py new file mode 100644 index 0000000..b480f47 --- /dev/null +++ b/app-instance/backend/beaver/services/skillhub_service.py @@ -0,0 +1,248 @@ +"""SkillHub marketplace client and installer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import posixpath +from typing import Any + +import httpx + +from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter +from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion +from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content + + +SKILLHUB_BASE_URL = "https://skillhub.bwgdi.com" +SKILLHUB_API_BASE = f"{SKILLHUB_BASE_URL}/api/web" + + +@dataclass(slots=True) +class SkillHubService: + store: SkillSpecStore + timeout_seconds: int = 30 + + async def search( + self, + *, + q: str = "", + sort: str = "relevance", + page: int = 0, + size: int = 12, + namespace: str | None = None, + ) -> dict[str, Any]: + params = { + "q": q, + "sort": sort, + "page": str(max(0, page)), + "size": str(max(1, min(size, 50))), + } + if namespace: + params["namespace"] = namespace.removeprefix("@") + data = await self._get_json("/skills", params=params) + payload = _unwrap(data) + if not isinstance(payload, dict): + payload = {} + items = [self._with_install_state(item) for item in list(payload.get("items") or [])] + return { + "items": items, + "total": int(payload.get("total") or len(items)), + "page": int(payload.get("page") or page), + "size": int(payload.get("size") or size), + } + + async def detail(self, namespace: str, slug: str) -> dict[str, Any]: + data = await self._get_json(f"/skills/{namespace.removeprefix('@')}/{slug}") + payload = _unwrap(data) + item = self._with_install_state(payload if isinstance(payload, dict) else {}) + return item + + async def version(self, namespace: str, slug: str, version: str) -> dict[str, Any]: + namespace = namespace.removeprefix("@") + detail = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}")) + files = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}/files")) + if not isinstance(detail, dict): + detail = {} + if not isinstance(files, list): + files = [] + return {"detail": detail, "files": files} + + async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]: + namespace = namespace.removeprefix("@") + skill = await self.detail(namespace, slug) + selected_version = version or _published_version(skill) + if not selected_version: + raise ValueError("SkillHub skill has no published version") + version_payload = await self.version(namespace, slug, selected_version) + files = list(version_payload.get("files") or []) + contents: dict[str, str] = {} + for item in files: + file_path = _safe_posix_path(str(item.get("filePath") or item.get("path") or "")) + contents[file_path] = await self._get_text( + f"/skills/{namespace}/{slug}/versions/{selected_version}/file", + params={"path": file_path}, + ) + skill_content = contents.get("SKILL.md") + if not skill_content: + raise ValueError("SkillHub version does not contain SKILL.md") + frontmatter, body = parse_frontmatter(skill_content) + skill_name = str(frontmatter.get("name") or skill.get("slug") or slug).strip() + if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}: + raise ValueError(f"Unsafe skill name from SkillHub: {skill_name}") + normalized_frontmatter = normalize_frontmatter( + { + **frontmatter, + "name": skill_name, + "description": frontmatter.get("description") or skill.get("summary") or skill_name, + } + ) + rendered = _render_skill_content(normalized_frontmatter, body) + content_hash = canonical_hash(rendered) + existing = self.store.read_published_skill(skill_name) + existing_spec = self.store.get_skill_spec(skill_name) + if existing is not None and existing.version.content_hash == content_hash: + return { + "ok": True, + "skill_name": skill_name, + "version": existing.version.version, + "source": "skillhub", + "namespace": namespace, + "slug": slug, + "installed_path": str(self.store.root / skill_name), + "already_installed": True, + } + next_version = self._next_version(skill_name) + now = datetime.now(timezone.utc).isoformat() + skill_version = SkillVersion( + skill_name=skill_name, + version=next_version, + content_hash=content_hash, + summary_hash=canonical_hash(strip_frontmatter(rendered).strip()), + created_at=now, + created_by="skillhub", + change_reason=f"Install SkillHub {namespace}/{slug}@{selected_version}", + parent_version=existing.version.version if existing is not None else None, + review_state="published", + frontmatter=normalized_frontmatter, + summary=summarize_skill_content(body), + tool_hints=self.store._extract_tool_hints(normalized_frontmatter), + provenance={ + "source": "skillhub", + "namespace": namespace, + "slug": slug, + "skillhub_version": selected_version, + "source_url": f"{SKILLHUB_BASE_URL}/space/{namespace}/{slug}", + }, + ) + self.store.write_skill_version(skill_version, rendered) + for file_path, content in contents.items(): + if file_path == "SKILL.md": + continue + target = self.store.root / skill_name / "versions" / next_version / file_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + spec = existing_spec or SkillSpec( + name=skill_name, + display_name=str(skill.get("displayName") or skill_name), + description=str(normalized_frontmatter.get("description") or skill_name), + created_at=now, + updated_at=now, + current_version=next_version, + status="active", + tags=[], + owners=["skillhub"], + source_kind="skillhub", + lineage=[], + ) + spec.current_version = next_version + spec.updated_at = now + spec.status = "active" + spec.source_kind = "skillhub" + if "skillhub" not in spec.owners: + spec.owners.append("skillhub") + self.store.write_skill_spec(spec) + self.store.set_current_version(skill_name, next_version) + published = self.store.read_index("published") + if skill_name not in published: + published.append(skill_name) + self.store.update_index("published", published) + return { + "ok": True, + "skill_name": skill_name, + "version": next_version, + "source": "skillhub", + "namespace": namespace, + "slug": slug, + "installed_path": str(self.store.root / skill_name), + "already_installed": False, + } + + async def _get_json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client: + response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params) + response.raise_for_status() + data = response.json() + return data if isinstance(data, dict) else {} + + async def _get_text(self, path: str, *, params: dict[str, str]) -> str: + async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client: + response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params) + response.raise_for_status() + return response.text + + def _with_install_state(self, item: dict[str, Any]) -> dict[str, Any]: + result = dict(item) + slug = str(result.get("slug") or result.get("displayName") or "") + namespace = str(result.get("namespace") or "").removeprefix("@") + installed = self.store.get_skill_spec(slug) or self._find_installed_skillhub_spec(namespace, slug) + result["installed"] = installed is not None and installed.status == "active" + result["installed_version"] = installed.current_version if installed is not None else None + return result + + def _find_installed_skillhub_spec(self, namespace: str, slug: str) -> SkillSpec | None: + for spec in self.store.list_skill_specs(): + loaded = self.store.read_published_skill(spec.name) + provenance = loaded.version.provenance if loaded is not None else {} + if provenance.get("source") == "skillhub" and provenance.get("namespace") == namespace and provenance.get("slug") == slug: + return spec + return None + + def _next_version(self, skill_name: str) -> str: + versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")] + numbers = [int(item[1:]) for item in versions if item[1:].isdigit()] + return f"v{(max(numbers) if numbers else 0) + 1:04d}" + + +def _unwrap(payload: dict[str, Any]) -> Any: + if "data" in payload: + return payload["data"] + return payload + + +def _published_version(item: dict[str, Any]) -> str | None: + for key in ("publishedVersion", "headlineVersion"): + value = item.get(key) + if isinstance(value, dict) and value.get("version"): + return str(value["version"]) + return None + + +def _safe_posix_path(value: str) -> str: + cleaned = posixpath.normpath(value.replace("\\", "/")).lstrip("/") + if cleaned in {"", ".", ".."} or cleaned.startswith("../") or "/../" in cleaned: + raise ValueError(f"Unsafe SkillHub file path: {value}") + return cleaned + + +def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: + lines = ["---"] + for key, value in normalize_frontmatter(frontmatter).items(): + if isinstance(value, list): + lines.append(f"{key}:") + for item in value: + lines.append(f" - {item}") + else: + lines.append(f"{key}: {value}") + lines.extend(["---", "", body.strip()]) + return "\n".join(lines).rstrip() + "\n" diff --git a/app-instance/backend/beaver/services/team_service.py b/app-instance/backend/beaver/services/team_service.py index a209525..20731c0 100644 --- a/app-instance/backend/beaver/services/team_service.py +++ b/app-instance/backend/beaver/services/team_service.py @@ -32,7 +32,7 @@ class TeamService: provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None, inherited_pinned_skills: list[str] | None = None, inherited_pinned_skill_contexts: list["SkillContext"] | None = None, - learning_candidate_enabled: bool = False, + allow_candidate_generation: bool = False, ) -> TeamRunResult: """Run a team graph inside the parent task context.""" @@ -46,7 +46,7 @@ class TeamService: provider_bundle_factory=provider_bundle_factory, inherited_pinned_skills=inherited_pinned_skills, inherited_pinned_skill_contexts=inherited_pinned_skill_contexts, - learning_candidate_enabled=learning_candidate_enabled, + allow_candidate_generation=allow_candidate_generation, ) self._attach_runs_to_parent_task(result) return result diff --git a/app-instance/backend/beaver/skills/assembler/task_assembler.py b/app-instance/backend/beaver/skills/assembler/task_assembler.py index a980d99..e95ae23 100644 --- a/app-instance/backend/beaver/skills/assembler/task_assembler.py +++ b/app-instance/backend/beaver/skills/assembler/task_assembler.py @@ -1,19 +1,22 @@ """LLM-driven skill assembler. -这层现在不再自己做规则打分,而是直接把: +这层现在不再自己做规则打分,而是分两步把: 1. task description 2. embedding 召回后的候选 skill 摘要 +3. 粗选候选的完整 skill 正文 交给一个模型来决定本轮要激活哪些 skill。 当前目标非常克制: -- 输入尽量简单 +- 主 agent 不拿 skill_view,也不动态探索技能库 +- SkillAssembler 可以在系统侧内部读取候选 skill 正文 - 输出只要 skill 名称 - 没有命中就返回空 skills """ from __future__ import annotations +import asyncio from dataclasses import dataclass, field import json from typing import Any @@ -31,6 +34,7 @@ class SkillAssemblyResult: """一次装配后真正要注入当前 run 的 skills。""" activated_skills: list[SkillContext] = field(default_factory=list) + llm_interactions: list[dict[str, Any]] = field(default_factory=list) class SkillAssembler: @@ -40,9 +44,14 @@ class SkillAssembler: self, loader: SkillsLoader, retriever: SkillEmbeddingRetriever | None = None, + *, + max_detailed_candidates: int = 5, + max_candidate_content_chars: int = 6000, ) -> None: self.loader = loader self.retriever = retriever or SkillEmbeddingRetriever() + self.max_detailed_candidates = max(1, max_detailed_candidates) + self.max_candidate_content_chars = max(1000, max_candidate_content_chars) async def assemble( self, @@ -51,6 +60,7 @@ class SkillAssembler: provider: LLMProvider, model: str, embedding_runtime: ProviderRuntime | None = None, + thinking_enabled: bool | None = None, top_k: int = 12, ) -> SkillAssemblyResult: candidates = self.loader.build_selection_candidates() @@ -71,15 +81,39 @@ class SkillAssembler: ) if not candidates: return SkillAssemblyResult() + llm_interactions: list[dict[str, Any]] = [] + if len(candidates) <= self.max_detailed_candidates: + shortlisted_names = [item["name"] for item in candidates] + else: + shortlisted_names = await self._select_skill_names( + task_description=task_description, + candidates=candidates, + provider=provider, + model=model, + thinking_enabled=thinking_enabled, + max_selected=self.max_detailed_candidates, + selection_stage="shortlist", + llm_interactions=llm_interactions, + ) + if not shortlisted_names: + return SkillAssemblyResult(llm_interactions=llm_interactions) + + detailed_candidates = self._build_detailed_candidates( + candidates=candidates, + selected_names=shortlisted_names, + ) selected_names = await self._select_skill_names( task_description=task_description, - candidates=candidates, + candidates=detailed_candidates, provider=provider, model=model, + thinking_enabled=thinking_enabled, + selection_stage="final", + llm_interactions=llm_interactions, ) if not selected_names: - return SkillAssemblyResult() + return SkillAssemblyResult(llm_interactions=llm_interactions) activated_skills: list[SkillContext] = [] for name in selected_names: @@ -99,7 +133,7 @@ class SkillAssembler: ) ) - return SkillAssemblyResult(activated_skills=activated_skills) + return SkillAssemblyResult(activated_skills=activated_skills, llm_interactions=llm_interactions) async def _select_skill_names( self, @@ -108,17 +142,28 @@ class SkillAssembler: candidates: list[dict[str, str]], provider: LLMProvider, model: str, + thinking_enabled: bool | None = None, + max_selected: int | None = None, + selection_stage: str = "final", + llm_interactions: list[dict[str, Any]] | None = None, + timeout_seconds: float = 8.0, ) -> list[str]: candidate_summary = self._render_candidates(candidates) candidate_names = {item["name"] for item in candidates} + selection_instruction = ( + f"Return at most {max_selected} names for detailed inspection. " + if max_selected is not None + else "Return the final skill names to activate. " + ) messages = [ { "role": "system", "content": ( "You select Beaver skills for a single run. " - "Given a task description and candidate skill summaries, " + "Given a task description and candidate skill information, " "return only a JSON array of skill names to activate. " - "Do not invent names. If nothing matches, return []." + "Do not invent names. If nothing matches, return []. " + f"Selection stage: {selection_stage}. {selection_instruction}" ), }, { @@ -130,13 +175,34 @@ class SkillAssembler: ), }, ] - response = await provider.chat( - messages=messages, - tools=None, - model=model, - max_tokens=512, - temperature=0, - ) + chat_kwargs: dict[str, Any] = { + "messages": messages, + "tools": None, + "model": model, + "max_tokens": 256, + "temperature": 0, + } + if thinking_enabled is not None: + chat_kwargs["thinking_enabled"] = thinking_enabled + try: + response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds) + except Exception: + return [] + if llm_interactions is not None: + llm_interactions.append( + { + "stage": selection_stage, + "model": model, + "messages": messages, + "response": { + "content": response.content, + "finish_reason": response.finish_reason, + "provider_name": response.provider_name, + "model": response.model, + "usage": response.usage, + }, + } + ) if response.finish_reason == "error" or not response.content: return [] @@ -149,15 +215,42 @@ class SkillAssembler: for name in parsed: if name in candidate_names and name not in filtered: filtered.append(name) - return filtered + return filtered[:max_selected] if max_selected is not None else filtered @staticmethod def _render_candidates(candidates: list[dict[str, str]]) -> str: lines: list[str] = [] for item in candidates: - lines.append(f"- {item['name']}: {item['description']}") + content = item.get("content") + if content: + lines.append( + f"## {item['name']}\n" + f"Description: {item['description']}\n" + f"Skill content:\n{content}" + ) + else: + lines.append(f"- {item['name']}: {item['description']}") return "\n".join(lines) + def _build_detailed_candidates( + self, + *, + candidates: list[dict[str, str]], + selected_names: list[str], + ) -> list[dict[str, str]]: + by_name = {item["name"]: item for item in candidates} + detailed: list[dict[str, str]] = [] + for name in selected_names: + candidate = by_name.get(name) + if candidate is None: + continue + raw_content = self.loader.load_published_skill(name) + content = strip_frontmatter(raw_content).strip() if raw_content else "" + if len(content) > self.max_candidate_content_chars: + content = content[: self.max_candidate_content_chars].rstrip() + "\n...[truncated]" + detailed.append({**candidate, "content": content}) + return detailed + @staticmethod def _parse_selected_names(content: str) -> list[str]: cleaned = content.strip() diff --git a/app-instance/backend/beaver/skills/catalog/loader.py b/app-instance/backend/beaver/skills/catalog/loader.py index bd626f7..e9a2543 100644 --- a/app-instance/backend/beaver/skills/catalog/loader.py +++ b/app-instance/backend/beaver/skills/catalog/loader.py @@ -244,12 +244,10 @@ class SkillsLoader: meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) available = check_requirements(meta_blob) description = frontmatter.get("description") or record.description or record.name - load_hint = f'Use skill_view(name="{record.name}") to load the full skill.' lines.append(f' ') lines.append(f" {escape_xml(record.name)}") lines.append(f" {escape_xml(description)}") lines.append(f" {escape_xml(record.version)}") - lines.append(f" {escape_xml(load_hint)}") support_files = self.list_skill_supporting_files(record.name) if support_files: lines.append(" ") diff --git a/app-instance/backend/beaver/skills/drafts/service.py b/app-instance/backend/beaver/skills/drafts/service.py index 027914e..546f939 100644 --- a/app-instance/backend/beaver/skills/drafts/service.py +++ b/app-instance/backend/beaver/skills/drafts/service.py @@ -124,6 +124,9 @@ class DraftService: def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None: return self.store.read_draft(skill_name, draft_id) + def delete_draft(self, skill_name: str, draft_id: str) -> bool: + return self.store.delete_draft(skill_name, draft_id) + def _utc_now() -> str: from datetime import datetime, timezone diff --git a/app-instance/backend/beaver/skills/learning/__init__.py b/app-instance/backend/beaver/skills/learning/__init__.py index c125a78..eb6d616 100644 --- a/app-instance/backend/beaver/skills/learning/__init__.py +++ b/app-instance/backend/beaver/skills/learning/__init__.py @@ -2,7 +2,12 @@ from .evidence import EvidencePacket, EvidenceSelector from .eval import SkillDraftEvaluator -from .missing_skill import MissingSkillDraftResult, MissingSkillSynthesizer +from .missing_skill import ( + EphemeralGuidanceResult, + EphemeralGuidanceSynthesizer, + MissingSkillDraftResult, + MissingSkillSynthesizer, +) from .pipeline import SkillLearningPipelineService from .service import RunReceiptContext, SkillLearningService from .synthesizer import SkillDraftSynthesizer @@ -12,6 +17,8 @@ __all__ = [ "EvidencePacket", "EvidenceSelector", "SkillDraftEvaluator", + "EphemeralGuidanceResult", + "EphemeralGuidanceSynthesizer", "MissingSkillDraftResult", "MissingSkillSynthesizer", "RunReceiptContext", diff --git a/app-instance/backend/beaver/skills/learning/missing_skill.py b/app-instance/backend/beaver/skills/learning/missing_skill.py index e947a6c..9c092bb 100644 --- a/app-instance/backend/beaver/skills/learning/missing_skill.py +++ b/app-instance/backend/beaver/skills/learning/missing_skill.py @@ -1,4 +1,4 @@ -"""Synthesize draft-only skills for missing sub-agent guidance.""" +"""Synthesize ephemeral guidance for missing sub-agent skills.""" from __future__ import annotations @@ -6,11 +6,10 @@ import json import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from uuid import uuid4 from beaver.engine.context import SkillContext from beaver.engine.providers import ProviderBundle -from beaver.skills.drafts import DraftService -from beaver.skills.specs import SkillDraft from beaver.skills.specs.serialization import canonical_hash if TYPE_CHECKING: @@ -18,13 +17,14 @@ if TYPE_CHECKING: @dataclass(slots=True) -class MissingSkillDraftResult: - draft: SkillDraft +class EphemeralGuidanceResult: + guidance_id: str + guidance_name: str skill_context: SkillContext -class MissingSkillSynthesizer: - """Create a draft skill and an ephemeral SkillContext for the current run.""" +class EphemeralGuidanceSynthesizer: + """Create one-run guidance for the current delegated sub-agent.""" async def synthesize( self, @@ -37,8 +37,7 @@ class MissingSkillSynthesizer: skill_query: str, required_capabilities: list[str], provider_bundle: ProviderBundle, - draft_service: DraftService, - ) -> MissingSkillDraftResult: + ) -> EphemeralGuidanceResult: 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) @@ -49,14 +48,14 @@ class MissingSkillSynthesizer: { "role": "system", "content": ( - "You create concise Beaver skill drafts. Return only JSON with keys: " - "skill_name, description, content, tags." + "You create concise Beaver ephemeral guidance. Return only JSON with keys: " + "guidance_name, description, content, tags." ), }, { "role": "user", "content": ( - "Create a procedural skill draft for this missing Task sub-agent guidance.\n\n" + "Create procedural guidance for this missing Task sub-agent capability.\n\n" f"Task goal:\n{task.goal}\n\n" f"Current user request:\n{user_message}\n\n" f"Node id: {node_id}\n" @@ -64,62 +63,37 @@ class MissingSkillSynthesizer: f"Skill query:\n{skill_query}\n" f"Required capabilities: {required_capabilities}\n\n" "The content must be actionable guidance for a temporary sub-agent. " - "Do not include implementation claims or publish metadata." + "Do not include implementation claims, review metadata, or publish metadata." ), }, ], tools=None, model=model, - max_tokens=1200, + max_tokens=4096, temperature=0, ) payload = self._parse_payload(response.content or "") or payload except Exception: payload = payload - skill_name = _slug(str(payload.get("skill_name") or skill_query or node_id)) + guidance_name = _slug(str(payload.get("guidance_name") or payload.get("skill_name") or skill_query or node_id)) + guidance_id = f"eg_{uuid4().hex}" content = str(payload.get("content") or "").strip() if not content: content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"]) - frontmatter = { - "description": str(payload.get("description") or f"Draft guidance for {skill_query or node_id}").strip(), - "tags": [str(item) for item in payload.get("tags") or ["generated", "task-sub-agent"]], - "metadata": { - "origin": "missing_task_subagent_skill", - "task_id": task.task_id, - "node_id": node_id, - "attempt_index": attempt_index, - "skill_query": skill_query, - "required_capabilities": list(required_capabilities), - }, - } - draft = draft_service.create_new_skill_draft( - skill_name=skill_name, - proposed_content=content, - proposed_frontmatter=frontmatter, - created_by="task-skill-resolver", - reason="generated_for_missing_task_subagent_skill", - trigger_session_id=task.session_id, - evidence_refs=[ - { - "task_id": task.task_id, - "session_id": task.session_id, - "attempt_index": attempt_index, - "node_id": node_id, - "skill_query": skill_query, - "required_capabilities": list(required_capabilities), - } - ], - ) context = SkillContext( - name=f"draft:{draft.skill_name}", - content=draft.proposed_content, - version=f"draft:{draft.draft_id}", - content_hash=canonical_hash(draft.proposed_content), - activation_reason="generated_missing_skill", + name=f"ephemeral:{guidance_name}", + content=content, + version=f"ephemeral:{guidance_id}", + content_hash=canonical_hash(content), + activation_reason="ephemeral_guidance", tool_hints=[], ) - return MissingSkillDraftResult(draft=draft, skill_context=context) + return EphemeralGuidanceResult( + guidance_id=guidance_id, + guidance_name=guidance_name, + skill_context=context, + ) @staticmethod def _parse_payload(text: str) -> dict[str, Any] | None: @@ -145,7 +119,7 @@ class MissingSkillSynthesizer: title = skill_query or node_task or "task subagent guidance" capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely." return { - "skill_name": _slug(title), + "guidance_name": _slug(title), "description": f"Draft guidance for {title}.", "tags": ["generated", "task-sub-agent"], "content": ( @@ -163,4 +137,8 @@ class MissingSkillSynthesizer: def _slug(value: str) -> str: cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") - return cleaned[:64].strip("-") or "generated-task-subagent-skill" + return cleaned[:64].strip("-") or "generated-task-subagent-guidance" + + +MissingSkillDraftResult = EphemeralGuidanceResult +MissingSkillSynthesizer = EphemeralGuidanceSynthesizer diff --git a/app-instance/backend/beaver/skills/learning/pipeline.py b/app-instance/backend/beaver/skills/learning/pipeline.py index 2abee12..3194710 100644 --- a/app-instance/backend/beaver/skills/learning/pipeline.py +++ b/app-instance/backend/beaver/skills/learning/pipeline.py @@ -14,6 +14,12 @@ from beaver.skills.publisher import SkillPublisher from beaver.skills.reviews import ReviewService from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion +_REJECTABLE_DRAFT_STATUSES = { + SkillReviewState.DRAFT.value, + SkillReviewState.IN_REVIEW.value, + SkillReviewState.APPROVED.value, +} + class SkillLearningPipelineService: """Coordinates candidate -> draft -> review -> publish lifecycle.""" @@ -161,6 +167,9 @@ class SkillLearningPipelineService: requested_by: str = "system", notes: str = "", ) -> SkillReviewRecord: + draft = self.get_draft(skill_name, draft_id) + if draft.status != SkillReviewState.DRAFT.value: + raise ValueError("Draft must be in draft status before review submission") safety = self.get_safety_report(skill_name, draft_id) if safety is not None and (not safety.passed or safety.risk_level == "critical"): raise ValueError("Draft cannot enter review because safety check failed") @@ -179,6 +188,12 @@ class SkillLearningPipelineService: reviewer: str = "system", notes: str = "", ) -> SkillReviewRecord: + draft = self.get_draft(skill_name, draft_id) + if draft.status != SkillReviewState.IN_REVIEW.value: + raise ValueError("Draft must be in review before approval") + safety = self.get_safety_report(skill_name, draft_id) + if safety is not None and (not safety.passed or safety.risk_level == "critical"): + raise ValueError("Draft cannot be approved because safety check failed") review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes) self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved") return review @@ -191,6 +206,9 @@ class SkillLearningPipelineService: reviewer: str = "system", notes: str = "", ) -> SkillReviewRecord: + draft = self.get_draft(skill_name, draft_id) + if draft.status not in _REJECTABLE_DRAFT_STATUSES: + raise ValueError("Draft is not rejectable from its current status") review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes) self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected") return review diff --git a/app-instance/backend/beaver/skills/learning/service.py b/app-instance/backend/beaver/skills/learning/service.py index 505ce86..18c064e 100644 --- a/app-instance/backend/beaver/skills/learning/service.py +++ b/app-instance/backend/beaver/skills/learning/service.py @@ -69,6 +69,94 @@ class SkillLearningService: existing_ids.add(candidate.candidate_id) return candidates + def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]: + """Build candidates scoped to a single validated and satisfied Task run.""" + + runs = [record for record in self.run_store.list_runs() if record.task_id == task_id] + trigger_run = next((record for record in runs if record.run_id == trigger_run_id), None) + if trigger_run is None or not self._is_confirmed_positive_run(trigger_run): + return [] + + source_runs = [record for record in runs if self._is_confirmed_positive_run(record)] + if not source_runs: + return [] + + candidates: list[SkillLearningCandidate] = [] + published_receipts = [ + receipt + for record in source_runs + for receipt in record.activated_skills + if self._is_published_skill_receipt(receipt) + ] + source_run_ids = [record.run_id for record in source_runs] + source_session_ids = list(dict.fromkeys(record.session_id for record in source_runs)) + + if not published_receipts: + candidates.append( + SkillLearningCandidate( + candidate_id=f"new:task:{task_id}", + kind="new_skill", + source_run_ids=source_run_ids, + source_session_ids=source_session_ids, + related_skill_names=[], + reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.", + evidence={"task_id": task_id, "trigger_run_id": trigger_run_id, "theme": self._task_theme(trigger_run.task_text)}, + status="open", + priority=1, + confidence=0.8, + trigger_reason="validation_accepted_and_user_satisfied", + ) + ) + else: + seen: set[tuple[str, str]] = set() + for receipt in published_receipts: + key = (receipt.skill_name, receipt.skill_version) + if key in seen: + continue + seen.add(key) + skill_runs = [ + record + for record in source_runs + if any( + item.skill_name == receipt.skill_name + and item.skill_version == receipt.skill_version + and self._is_published_skill_receipt(item) + for item in record.activated_skills + ) + ] + candidates.append( + SkillLearningCandidate( + candidate_id=f"revise:{receipt.skill_name}:{receipt.skill_version}:task:{task_id}", + kind="revise_skill", + source_run_ids=[record.run_id for record in skill_runs], + source_session_ids=list(dict.fromkeys(record.session_id for record in skill_runs)), + related_skill_names=[receipt.skill_name], + reason=( + f"Task {task_id} succeeded with published skill " + f"{receipt.skill_name}/{receipt.skill_version}; consider whether the skill should capture this evidence." + ), + evidence={ + "task_id": task_id, + "trigger_run_id": trigger_run_id, + "skill_version": receipt.skill_version, + }, + status="open", + priority=1, + confidence=0.7, + trigger_reason="validation_accepted_and_user_satisfied", + ) + ) + + existing_ids = {item.candidate_id for item in self.learning_store.list_learning_candidates()} + created: list[SkillLearningCandidate] = [] + for candidate in candidates: + if candidate.candidate_id in existing_ids: + continue + self.learning_store.record_learning_candidate(candidate) + existing_ids.add(candidate.candidate_id) + created.append(candidate) + return created + async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any: candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()} candidate = candidates.get(candidate_id) @@ -181,7 +269,7 @@ class SkillLearningService: groups.setdefault(key, []).append(record) candidates: list[SkillLearningCandidate] = [] for theme, runs in groups.items(): - successful = [record for record in runs if record.success] + successful = [record for record in runs if self._is_confirmed_positive_run(record)] if len(successful) < 2: continue if any(record.activated_skills for record in successful): @@ -202,6 +290,8 @@ class SkillLearningService: def _build_merge_candidates(self) -> list[SkillLearningCandidate]: pair_counts: dict[tuple[str, str], list[RunRecord]] = {} for record in self.run_store.list_runs(): + if not self._is_confirmed_positive_run(record): + continue unique = sorted({receipt.skill_name for receipt in record.activated_skills}) for pair in combinations(unique, 2): pair_counts.setdefault(pair, []).append(record) @@ -260,6 +350,25 @@ class SkillLearningService: effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version)) return effects + @staticmethod + def _is_confirmed_positive_run(record: RunRecord) -> bool: + validation = record.validation_result or {} + feedback = record.feedback or {} + return ( + bool(record.success) + and bool(record.task_id) + and validation.get("accepted") is True + and feedback.get("feedback_type") == "satisfied" + ) + + @staticmethod + def _is_published_skill_receipt(receipt: SkillActivationReceipt) -> bool: + return ( + not receipt.skill_name.startswith(("draft:", "ephemeral:")) + and not receipt.skill_version.startswith(("draft:", "ephemeral:")) + and receipt.activation_reason not in {"generated_missing_skill", "ephemeral_guidance"} + ) + @staticmethod def _candidate_id(kind: str, *parts: str) -> str: return f"{kind}:{'|'.join(parts)}" diff --git a/app-instance/backend/beaver/skills/learning/synthesizer.py b/app-instance/backend/beaver/skills/learning/synthesizer.py index 6ddb255..b667c93 100644 --- a/app-instance/backend/beaver/skills/learning/synthesizer.py +++ b/app-instance/backend/beaver/skills/learning/synthesizer.py @@ -60,7 +60,7 @@ class SkillDraftSynthesizer: ], tools=None, model=model, - max_tokens=1500, + max_tokens=4096, temperature=0, ) payload = self._parse_payload(response.content or "") diff --git a/app-instance/backend/beaver/skills/publisher/service.py b/app-instance/backend/beaver/skills/publisher/service.py index 5e0206c..bbcc869 100644 --- a/app-instance/backend/beaver/skills/publisher/service.py +++ b/app-instance/backend/beaver/skills/publisher/service.py @@ -2,6 +2,9 @@ from __future__ import annotations +import shutil +from pathlib import Path + from beaver.skills.catalog.utils import strip_frontmatter from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content @@ -44,6 +47,7 @@ class SkillPublisher: }, ) self.store.write_skill_version(version, content) + self._copy_uploaded_supporting_files(draft, next_version) self.store.set_current_version(skill_name, next_version) spec = self.store.get_skill_spec(skill_name) @@ -169,6 +173,27 @@ class SkillPublisher: self.store.update_index("published", published) self.store.update_index("disabled", disabled) + def _copy_uploaded_supporting_files(self, draft: SkillDraft, version: str) -> None: + for evidence in draft.evidence_refs: + if not isinstance(evidence, dict) or evidence.get("kind") != "upload": + continue + raw_dir = evidence.get("supporting_upload_dir") + if not raw_dir: + continue + source_root = Path(str(raw_dir)) + if not source_root.exists() or not source_root.is_dir(): + continue + target_root = self.store.root / draft.skill_name / "versions" / version + for source in sorted(source_root.rglob("*")): + if not source.is_file() or source.is_symlink(): + continue + relative = source.relative_to(source_root) + if any(part in {"", ".", ".."} for part in relative.parts): + continue + target = target_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft: draft = self.store.read_draft(skill_name, draft_id) if draft is None: diff --git a/app-instance/backend/beaver/skills/reviews/service.py b/app-instance/backend/beaver/skills/reviews/service.py index a26f9c4..d8edecc 100644 --- a/app-instance/backend/beaver/skills/reviews/service.py +++ b/app-instance/backend/beaver/skills/reviews/service.py @@ -47,8 +47,6 @@ class ReviewService: def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord: draft = self._require_draft(skill_name, draft_id) - draft.status = SkillReviewState.REJECTED.value - self.store.write_draft(draft) review = SkillReviewRecord( review_id=uuid4().hex, draft_id=draft_id, @@ -61,6 +59,7 @@ class ReviewService: notes=notes, ) self.store.write_review(review) + self.store.delete_draft(skill_name, draft_id) return review def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft: diff --git a/app-instance/backend/beaver/skills/specs/storage.py b/app-instance/backend/beaver/skills/specs/storage.py index 6610f51..4530a49 100644 --- a/app-instance/backend/beaver/skills/specs/storage.py +++ b/app-instance/backend/beaver/skills/specs/storage.py @@ -87,6 +87,11 @@ class SkillSpecStore: return str(self._read_json(current_path).get("current_version") or "") or None if (directory / "SKILL.md").exists(): return "legacy" + versions_dir = directory / "versions" + if versions_dir.exists(): + versions = [child.name for child in sorted(versions_dir.iterdir()) if child.is_dir()] + if versions: + return versions[-1] spec = self.get_skill_spec(name) if spec is not None and spec.current_version: return spec.current_version @@ -182,6 +187,13 @@ class SkillSpecStore: drafts_dir.mkdir(parents=True, exist_ok=True) self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict()) + def delete_draft(self, skill_name: str, draft_id: str) -> bool: + path = self._skill_dir(skill_name) / "drafts" / f"draft-{draft_id}.json" + if not path.exists(): + return False + path.unlink() + return True + def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]: reviews_dir = self._skill_dir(skill_name) / "reviews" if not reviews_dir.exists(): @@ -199,6 +211,19 @@ class SkillSpecStore: reviews_dir.mkdir(parents=True, exist_ok=True) self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict()) + def delete_reviews_for_draft(self, skill_name: str, draft_id: str) -> int: + reviews_dir = self._skill_dir(skill_name) / "reviews" + if not reviews_dir.exists(): + return 0 + deleted = 0 + for path in sorted(reviews_dir.glob("review-*.json")): + record = SkillReviewRecord.from_dict(self._read_json(path)) + if record.draft_id != draft_id: + continue + path.unlink() + deleted += 1 + return deleted + def update_index(self, index_name: str, values: list[str]) -> None: self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))}) diff --git a/app-instance/backend/beaver/tasks/models.py b/app-instance/backend/beaver/tasks/models.py index 994856b..007c70e 100644 --- a/app-instance/backend/beaver/tasks/models.py +++ b/app-instance/backend/beaver/tasks/models.py @@ -160,6 +160,9 @@ class MainAgentDecision: mode: str reason: str starts_new_task: bool = False + closes_task: bool = False + abandons_task: bool = False + short_title: str | None = None @property def is_task(self) -> bool: diff --git a/app-instance/backend/beaver/tasks/planner.py b/app-instance/backend/beaver/tasks/planner.py index 5064735..fcf06ee 100644 --- a/app-instance/backend/beaver/tasks/planner.py +++ b/app-instance/backend/beaver/tasks/planner.py @@ -50,10 +50,10 @@ class TaskExecutionPlan: for node in nodes for name in node.inherited_pinned_skills ], - "generated_skill_draft_ids": [ - item.generated_skill_draft_id + "ephemeral_guidance_ids": [ + item.ephemeral_guidance_id for item in self.skill_resolution_report - if item.generated_skill_draft_id + if item.ephemeral_guidance_id ], "skill_resolution_report": [item.to_dict() for item in self.skill_resolution_report], "fallback_error": self.fallback_error, @@ -108,7 +108,7 @@ class TaskExecutionPlanner: ], tools=None, model=model, - max_tokens=1200, + max_tokens=4096, temperature=0.0, ) plan = self.from_json(response.content or "") diff --git a/app-instance/backend/beaver/tasks/router.py b/app-instance/backend/beaver/tasks/router.py index 63726e5..36aa709 100644 --- a/app-instance/backend/beaver/tasks/router.py +++ b/app-instance/backend/beaver/tasks/router.py @@ -1,40 +1,144 @@ -"""Main Agent routing between simple chat and internal Task mode.""" +"""LLM-based routing between simple chat and internal Task mode.""" from __future__ import annotations -import re +import asyncio +import json +from typing import Any from .models import MainAgentDecision, TaskRecord class MainAgentRouter: - """Small deterministic classifier used before the main AgentLoop. + """Semantic router for deciding whether a message belongs to a Task.""" - The first version intentionally avoids a mandatory model call so the router - stays reliable during provider outages. The rule set is conservative: - anything that implies execution, files, tools, iteration, or validation - becomes Task mode. - """ + async def classify( + self, + message: str, + *, + active_task: TaskRecord | None = None, + provider: Any | None = None, + model: str | None = None, + recent_messages: list[dict[str, Any]] | None = None, + thinking_enabled: bool | None = None, + timeout_seconds: float = 8.0, + ) -> MainAgentDecision: + if provider is None: + return self._fallback(active_task=active_task, reason="router_provider_unavailable") + try: + chat_kwargs: dict[str, Any] = { + "messages": [ + { + "role": "system", + "content": ( + "You route user messages for Beaver's internal Task mode. " + "Return only compact JSON. Do not explain." + ), + }, + { + "role": "user", + "content": self._prompt( + message=message, + active_task=active_task, + recent_messages=recent_messages or [], + ), + }, + ], + "tools": None, + "model": model, + "max_tokens": 256, + "temperature": 0.0, + } + if thinking_enabled is not None: + chat_kwargs["thinking_enabled"] = thinking_enabled + response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds) + return self.from_json(response.content or "", active_task=active_task) + except Exception as exc: + return self._fallback(active_task=active_task, reason=f"router_failed: {exc}") - _TASK_PATTERNS = [ - r"\b(implement|fix|debug|refactor|migrate|build|create|write|edit|update|test|validate|deploy)\b", - r"\b(file|repo|code|project|backend|frontend|api|database|migration|pull request|ci|bug)\b", - r"\b(step|multi-step|workflow|plan and|then)\b", - r"(实现|修复|调试|重构|迁移|构建|创建|编写|修改|更新|测试|验证|部署|文件|代码|项目|前端|后端|接口|数据库|多步|任务)", - ] - _NEW_TASK_PATTERNS = [ - r"\b(new task|another task|different task|start over)\b", - r"(新任务|另一个任务|换个任务|重新开始)", - ] + def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision: + payload = self._parse_json_object(text) + raw_action = str(payload.get("action") or payload.get("mode") or "").strip().lower() + reason = str(payload.get("reason") or raw_action or "llm_router") + short_title = _clean_short_title(payload.get("short_title") or payload.get("title")) - def classify(self, message: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision: - text = message.strip() - lowered = text.lower() - starts_new = any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._NEW_TASK_PATTERNS) - if active_task is not None and active_task.status in {"awaiting_feedback", "needs_revision"} and not starts_new: - return MainAgentDecision(mode="task", reason="continuing_open_task", starts_new_task=False) - if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._TASK_PATTERNS): - return MainAgentDecision(mode="task", reason="task_pattern_matched", starts_new_task=starts_new) - if len(text) > 240: - return MainAgentDecision(mode="task", reason="long_request", starts_new_task=starts_new) - return MainAgentDecision(mode="simple", reason="simple_question", starts_new_task=False) + if raw_action in {"continue_task", "continue", "task"}: + return MainAgentDecision(mode="task", reason=reason, short_title=short_title) + if raw_action in {"new_task", "new"}: + return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title) + if raw_action in {"close_task", "close", "done", "finish"}: + return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title) + if raw_action in {"abandon_task", "abandon", "cancel_task"}: + return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title) + return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title) + + def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision: + if active_task is not None: + return MainAgentDecision(mode="task", reason=reason) + return MainAgentDecision(mode="simple", reason=reason) + + @staticmethod + def _prompt( + *, + message: str, + active_task: TaskRecord | None, + recent_messages: list[dict[str, Any]], + ) -> str: + active_task_payload = None + if active_task is not None: + active_task_payload = { + "task_id": active_task.task_id, + "description": active_task.description, + "goal": active_task.goal, + "status": active_task.status, + "short_title": active_task.metadata.get("short_title"), + } + recent = [ + {"role": item.get("role"), "content": str(item.get("content") or "")[:500]} + for item in recent_messages[-8:] + if item.get("role") in {"user", "assistant"} + ] + return ( + "Decide how to route the current user message.\n\n" + "Actions:\n" + "- simple_chat: no Task should be created or continued.\n" + "- continue_task: keep the user in the active Task.\n" + "- new_task: start a separate new Task.\n" + "- close_task: user explicitly says the active Task is done/satisfactory/finished.\n" + "- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n" + "Critical policy:\n" + "- If there is an active Task, choose continue_task unless the user's topic is completely unrelated " + "to that Task or the user explicitly closes/abandons it.\n" + "- Follow-up questions, corrections, partial changes, extra constraints, and result discussion stay in continue_task.\n" + "- Use new_task only when the user clearly asks to start a different task.\n" + "- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, " + "implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n" + "- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n" + "Return JSON only with keys: action, reason, short_title.\n\n" + f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n" + f"Recent conversation:\n{json.dumps(recent, ensure_ascii=False)}\n\n" + f"Current user message:\n{message}" + ) + + @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("router response must be a JSON object") + return payload + + +def _clean_short_title(value: Any) -> str | None: + if value in (None, ""): + return None + title = " ".join(str(value).strip().split()) + return title[:40] or None diff --git a/app-instance/backend/beaver/tasks/service.py b/app-instance/backend/beaver/tasks/service.py index 45a628e..f074f57 100644 --- a/app-instance/backend/beaver/tasks/service.py +++ b/app-instance/backend/beaver/tasks/service.py @@ -24,6 +24,8 @@ class TaskService: metadata: dict[str, Any] | None = None, ) -> TaskRecord: now = self._now() + task_metadata = dict(metadata or {}) + task_metadata.setdefault("short_title", short_task_title(description)) task = TaskRecord( task_id=uuid4().hex, session_id=session_id, @@ -35,7 +37,7 @@ class TaskService: creator=creator, created_at=now, updated_at=now, - metadata=dict(metadata or {}), + metadata=task_metadata, ) self.store.upsert_task(task) self._event(task, "created", payload={"description": description}) @@ -44,11 +46,45 @@ class TaskService: def get_task(self, task_id: str) -> TaskRecord | None: return self.store.get_task(task_id) + def list_tasks(self) -> list[TaskRecord]: + return sorted(self.store.list_tasks(), key=lambda item: item.updated_at, reverse=True) + + def list_events(self, task_id: str) -> list[TaskEvent]: + return self.store.list_events(task_id=task_id) + def get_task_by_run_id(self, run_id: str) -> TaskRecord | None: return self.store.get_task_by_run_id(run_id) - def get_latest_open_task(self, session_id: str) -> TaskRecord | None: - return self.store.get_latest_open_task(session_id) + def get_latest_open_task(self, session_id: str, *, include_unengaged_scheduled: bool = False) -> TaskRecord | None: + tasks = [ + task + for task in self.store.list_tasks() + if task.session_id == session_id and task.is_open + ] + if not include_unengaged_scheduled: + tasks = [task for task in tasks if self._is_user_visible_active_task(task)] + if not tasks: + return None + return sorted(tasks, key=lambda item: item.updated_at)[-1] + + def active_task_view(self, session_id: str) -> dict[str, Any] | None: + task = self.get_latest_open_task(session_id) + if task is None: + return None + return self.to_api_dict(task) + + def to_api_dict(self, task: TaskRecord) -> dict[str, Any]: + payload = task.to_dict() + payload["short_title"] = self.ensure_short_title(task).metadata.get("short_title") + payload["is_open"] = task.is_open + return payload + + def ensure_short_title(self, task: TaskRecord) -> TaskRecord: + if task.metadata.get("short_title"): + return task + task.metadata["short_title"] = short_task_title(task.description or task.goal or task.task_id) + self.store.upsert_task(task) + return task def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord: task = self._require(task_id) @@ -136,6 +172,38 @@ class TaskService: self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry) return task + def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord: + task = self._require(task_id) + now = self._now() + task.status = "closed" + task.closed_at = now + task.close_reason = reason + task.updated_at = now + self.store.upsert_task(task) + self._event(task, "closed", payload={"reason": reason}) + return task + + def abandon_task(self, task_id: str, *, reason: str = "abandoned") -> TaskRecord: + task = self._require(task_id) + now = self._now() + task.status = "abandoned" + task.closed_at = now + task.close_reason = reason + task.updated_at = now + self.store.upsert_task(task) + self._event(task, "abandoned", payload={"reason": reason}) + return task + + def delete_task(self, task_id: str) -> bool: + return self.store.delete_task(task_id) + + @staticmethod + def _is_user_visible_active_task(task: TaskRecord) -> bool: + if task.creator != "cron": + return True + metadata = task.metadata or {} + return bool(metadata.get("user_engaged") or metadata.get("requires_followup")) + def _require(self, task_id: str) -> TaskRecord: task = self.store.get_task(task_id) if task is None: @@ -165,3 +233,15 @@ class TaskService: @staticmethod def _now() -> str: return datetime.now(timezone.utc).isoformat() + + +def short_task_title(text: str) -> str: + cleaned = " ".join((text or "").strip().split()) + if not cleaned: + return "当前任务" + if any("\u4e00" <= char <= "\u9fff" for char in cleaned): + return cleaned[:15] + words = cleaned.split() + if len(words) <= 4: + return cleaned[:40] + return " ".join(words[:4])[:40] diff --git a/app-instance/backend/beaver/tasks/skill_resolver.py b/app-instance/backend/beaver/tasks/skill_resolver.py index eec1998..606d872 100644 --- a/app-instance/backend/beaver/tasks/skill_resolver.py +++ b/app-instance/backend/beaver/tasks/skill_resolver.py @@ -11,7 +11,7 @@ from beaver.engine.providers import ProviderBundle from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever from beaver.skills.catalog.loader import SkillsLoader from beaver.skills.drafts import DraftService -from beaver.skills.learning import MissingSkillSynthesizer +from beaver.skills.learning import EphemeralGuidanceSynthesizer from beaver.tasks.models import TaskRecord @@ -21,8 +21,8 @@ class SkillResolutionReport: skill_query: str required_capabilities: list[str] = field(default_factory=list) selected_skill_names: list[str] = field(default_factory=list) - generated_skill_draft_id: str | None = None - generated_skill_name: str | None = None + ephemeral_guidance_id: str | None = None + ephemeral_guidance_name: str | None = None ephemeral_used: bool = False reason: str = "" @@ -32,15 +32,15 @@ class SkillResolutionReport: "skill_query": self.skill_query, "required_capabilities": list(self.required_capabilities), "selected_skill_names": list(self.selected_skill_names), - "generated_skill_draft_id": self.generated_skill_draft_id, - "generated_skill_name": self.generated_skill_name, + "ephemeral_guidance_id": self.ephemeral_guidance_id, + "ephemeral_guidance_name": self.ephemeral_guidance_name, "ephemeral_used": self.ephemeral_used, "reason": self.reason, } class TaskSkillResolver: - """Pins published or draft-only skills onto generic team nodes.""" + """Pins published skills or one-run guidance onto generic team nodes.""" def __init__( self, @@ -48,12 +48,12 @@ class TaskSkillResolver: skills_loader: SkillsLoader, draft_service: DraftService, retriever: SkillEmbeddingRetriever | None = None, - missing_skill_synthesizer: MissingSkillSynthesizer | None = None, + missing_skill_synthesizer: EphemeralGuidanceSynthesizer | None = None, ) -> None: self.skills_loader = skills_loader self.draft_service = draft_service self.retriever = retriever or SkillEmbeddingRetriever() - self.missing_skill_synthesizer = missing_skill_synthesizer or MissingSkillSynthesizer() + self.missing_skill_synthesizer = missing_skill_synthesizer or EphemeralGuidanceSynthesizer() async def resolve_graph( self, @@ -138,7 +138,6 @@ class TaskSkillResolver: skill_query=skill_query, required_capabilities=required_capabilities, provider_bundle=provider_bundle, - draft_service=self.draft_service, ) resolved = self._generic_node( node, @@ -149,8 +148,8 @@ class TaskSkillResolver: "skill_query": skill_query, "required_capabilities": required_capabilities, "selected_skill_names": [], - "generated_skill_draft_id": missing.draft.draft_id, - "generated_skill_name": missing.draft.skill_name, + "ephemeral_guidance_id": missing.guidance_id, + "ephemeral_guidance_name": missing.guidance_name, "ephemeral_skill_names": [missing.skill_context.name], }, ) @@ -158,10 +157,10 @@ class TaskSkillResolver: node_id=node.node_id, skill_query=skill_query, required_capabilities=required_capabilities, - generated_skill_draft_id=missing.draft.draft_id, - generated_skill_name=missing.draft.skill_name, + ephemeral_guidance_id=missing.guidance_id, + ephemeral_guidance_name=missing.guidance_name, ephemeral_used=True, - reason="generated draft-only skill for missing sub-agent guidance", + reason="generated ephemeral guidance for missing sub-agent capability", ) async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]: @@ -215,7 +214,7 @@ class TaskSkillResolver: ], tools=None, model=model, - max_tokens=512, + max_tokens=2048, temperature=0, ) parsed = self._parse_names(response.content or "") diff --git a/app-instance/backend/beaver/tasks/store.py b/app-instance/backend/beaver/tasks/store.py index 06bda23..77c6ed6 100644 --- a/app-instance/backend/beaver/tasks/store.py +++ b/app-instance/backend/beaver/tasks/store.py @@ -40,7 +40,7 @@ class TaskStore: tasks = [ task for task in self.list_tasks() - if task.session_id == session_id and task.status in {"awaiting_feedback", "needs_revision", "open", "running"} + if task.session_id == session_id and task.is_open ] if not tasks: return None @@ -52,6 +52,25 @@ class TaskStore: payload[task.task_id] = task.to_dict() self._write_tasks_unlocked(payload) + def delete_task(self, task_id: str) -> bool: + with self._lock: + payload = self._read_tasks_unlocked() + if task_id not in payload: + return False + payload.pop(task_id, None) + self._write_tasks_unlocked(payload) + if self.events_path.exists(): + kept = [] + for line in self.events_path.read_text(encoding="utf-8").splitlines(): + cleaned = line.strip() + if not cleaned: + continue + event_payload = json.loads(cleaned) + if not isinstance(event_payload, dict) or str(event_payload.get("task_id")) != task_id: + kept.append(cleaned) + self.events_path.write_text(("\n".join(kept) + "\n") if kept else "", encoding="utf-8") + return True + def append_event(self, event: TaskEvent) -> None: self.events_path.parent.mkdir(parents=True, exist_ok=True) with self._lock: diff --git a/app-instance/backend/beaver/tasks/validation.py b/app-instance/backend/beaver/tasks/validation.py index 95cecc2..7741e09 100644 --- a/app-instance/backend/beaver/tasks/validation.py +++ b/app-instance/backend/beaver/tasks/validation.py @@ -84,7 +84,7 @@ class ValidationService: ], tools=None, model=model, - max_tokens=800, + max_tokens=4096, temperature=0.0, ) payload = self._parse_json_object(response.content or "") diff --git a/app-instance/backend/beaver/tools/assembler/task_assembler.py b/app-instance/backend/beaver/tools/assembler/task_assembler.py index 005b770..3644ae1 100644 --- a/app-instance/backend/beaver/tools/assembler/task_assembler.py +++ b/app-instance/backend/beaver/tools/assembler/task_assembler.py @@ -29,7 +29,7 @@ class ToolAssembler: always_tool_names: Sequence[str] | None = None, ) -> None: self.retriever = retriever or EmbeddingRetriever() - self.always_tool_names = tuple(always_tool_names or ("memory", "session_search", "skill_view")) + self.always_tool_names = tuple(always_tool_names or ("memory", "session_search")) async def assemble( self, diff --git a/app-instance/backend/beaver/tools/base.py b/app-instance/backend/beaver/tools/base.py index a107f19..668bd39 100644 --- a/app-instance/backend/beaver/tools/base.py +++ b/app-instance/backend/beaver/tools/base.py @@ -39,6 +39,7 @@ class ToolSpec: input_schema: dict[str, Any] toolset: str = "core" always_available: bool = False + metadata: dict[str, Any] = field(default_factory=dict) def to_mcp_descriptor(self) -> dict[str, Any]: """导出 MCP ListTools 风格的工具描述。 @@ -180,6 +181,8 @@ class ObjectBackedTool(BaseTool): arguments["current_session_id"] = context.session_id if "workspace" not in arguments and hasattr(self.backend, "workspace"): arguments["workspace"] = context.workspace + if "metadata" not in arguments: + arguments["metadata"] = context.metadata @staticmethod def _normalize_output(content: Any) -> dict[str, Any]: diff --git a/app-instance/backend/beaver/tools/builtins/__init__.py b/app-instance/backend/beaver/tools/builtins/__init__.py index d463d46..8afd195 100644 --- a/app-instance/backend/beaver/tools/builtins/__init__.py +++ b/app-instance/backend/beaver/tools/builtins/__init__.py @@ -1,19 +1,39 @@ """Built-in Beaver tools.""" +from .cron import CronTool from .echo import EchoTool, echo_tool -from .filesystem import ListDirectoryTool, ReadFileTool, SearchFilesTool +from .filesystem import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool from .memory import MemoryTool, memory_tool +from .skills_admin import SkillManageTool, SkillsListTool from .skill_view import SkillViewTool, skill_view from .session_search import SessionSearchTool, session_search +from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool +from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool +from .web import WebFetchTool, WebSearchTool __all__ = [ "EchoTool", + "ExecuteCodeTool", + "CronTool", + "DelegateTool", "ListDirectoryTool", "MemoryTool", + "PatchFileTool", + "ProcessTool", "ReadFileTool", "SearchFilesTool", + "SendMessageTool", + "SpawnTool", + "SkillManageTool", + "SkillsListTool", "SkillViewTool", "SessionSearchTool", + "TerminalTool", + "TodoTool", + "ClarifyTool", + "WebFetchTool", + "WebSearchTool", + "WriteFileTool", "echo_tool", "memory_tool", "skill_view", diff --git a/app-instance/backend/beaver/tools/builtins/cron.py b/app-instance/backend/beaver/tools/builtins/cron.py new file mode 100644 index 0000000..a3834cf --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/cron.py @@ -0,0 +1,163 @@ +"""Built-in cron tool for managing scheduled Beaver Tasks.""" + +from __future__ import annotations + +import json +from typing import Any + +from beaver.services.cron_service import CronService, schedule_from_api +from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec + + +CRON_TOOL_DESCRIPTION = ( + "Create and manage scheduled Beaver notifications or Tasks. Notification mode " + "sends scheduled results to the fixed notification session; task mode creates " + "a Task run. Actions: add, list, remove, toggle, run." +) + +CRON_TOOL_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "list", "remove", "toggle", "run"], + "description": "The scheduled-task operation to perform.", + }, + "name": { + "type": "string", + "description": "Short scheduled-task name. Optional for add.", + }, + "message": { + "type": "string", + "description": "The task instruction to run when the schedule triggers. Required for add.", + }, + "schedule": { + "type": "string", + "description": "Hermes-style schedule, for example 'every 15m', '0 9 * * *', or an ISO datetime.", + }, + "every_seconds": { + "type": "integer", + "minimum": 1, + "description": "Fixed interval in seconds for recurring scheduled tasks.", + }, + "cron_expr": { + "type": "string", + "description": "Cron expression such as '0 9 * * *'.", + }, + "tz": { + "type": "string", + "description": "IANA timezone for cron_expr, for example 'Asia/Shanghai'.", + }, + "at_iso": { + "type": "string", + "description": "ISO datetime for one-time scheduled tasks.", + }, + "job_id": { + "type": "string", + "description": "Scheduled-task ID for remove, toggle, or run.", + }, + "enabled": { + "type": "boolean", + "description": "Whether the scheduled task should be enabled when action is toggle.", + }, + "mode": { + "type": "string", + "enum": ["notification", "task"], + "description": "Use notification for reminders/reports; use task only when the scheduled work requires Task tracking.", + }, + "requires_followup": { + "type": "boolean", + "description": "Whether a task-mode scheduled run should appear as an active task awaiting user follow-up.", + }, + }, + "required": ["action"], +} + + +class CronTool(BaseTool): + """Tool-facing wrapper around the process CronService.""" + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="cron", + description=CRON_TOOL_DESCRIPTION, + input_schema=CRON_TOOL_PARAMETERS, + toolset="cron", + always_available=False, + ) + + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + try: + result = await self._invoke(arguments, context) + return ToolResult( + success=bool(result.get("success", True)), + content=json.dumps(result, ensure_ascii=False), + tool_name=self.spec.name, + error=str(result.get("error")) if result.get("error") else None, + raw_output=result, + ) + except Exception as exc: + return ToolResult( + success=False, + content=json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False), + tool_name=self.spec.name, + error=str(exc), + ) + + async def _invoke(self, arguments: dict[str, Any], context: ToolContext) -> dict[str, Any]: + service = self._resolve_cron_service(context) + action = str(arguments.get("action") or "").strip().lower() + if action == "add": + schedule = schedule_from_api(arguments) + job = service.add_job( + name=str(arguments.get("name") or "").strip(), + message=str(arguments.get("message") or "").strip(), + schedule=schedule, + session_key=str(arguments.get("session_key") or context.session_id or "").strip() or None, + payload_kind="agent_turn", + mode=str(arguments.get("mode") or "notification").strip().lower(), + requires_followup=bool(arguments.get("requires_followup", False)), + ) + return {"success": True, "job": job.to_api_dict()} + if action == "list": + include_disabled = bool(arguments.get("include_disabled", True)) + return { + "success": True, + "jobs": [job.to_api_dict() for job in service.list_jobs(include_disabled=include_disabled)], + } + if action == "remove": + job_id = _required_job_id(arguments) + return {"success": service.remove_job(job_id), "job_id": job_id} + if action == "toggle": + job_id = _required_job_id(arguments) + job = service.update_enabled(job_id, bool(arguments.get("enabled", True))) + if job is None: + return {"success": False, "error": f"Scheduled task {job_id!r} was not found."} + return {"success": True, "job": job.to_api_dict()} + if action == "run": + job_id = _required_job_id(arguments) + ok = await service.run_job(job_id, force=True) + job = service.get_job(job_id) + return { + "success": ok, + "job_id": job_id, + "job": job.to_api_dict() if job is not None else None, + } + return {"success": False, "error": "action must be one of: add, list, remove, toggle, run"} + + @staticmethod + def _resolve_cron_service(context: ToolContext) -> CronService: + service = context.get("cron_service") + if isinstance(service, CronService): + return service + if not context.workspace: + raise RuntimeError("Cron service is unavailable for this runtime.") + return CronService(f"{context.workspace}/cron/jobs.json") + + +def _required_job_id(arguments: dict[str, Any]) -> str: + job_id = str(arguments.get("job_id") or "").strip() + if not job_id: + raise ValueError("job_id is required") + return job_id diff --git a/app-instance/backend/beaver/tools/builtins/filesystem.py b/app-instance/backend/beaver/tools/builtins/filesystem.py index 3f0f5bd..0603457 100644 --- a/app-instance/backend/beaver/tools/builtins/filesystem.py +++ b/app-instance/backend/beaver/tools/builtins/filesystem.py @@ -116,6 +116,25 @@ SEARCH_FILES_PARAMETERS: dict[str, Any] = { "required": ["query"], } +WRITE_FILE_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path relative to the current workspace."}, + "content": {"type": "string", "description": "Full file content to write."}, + }, + "required": ["path", "content"], +} + +PATCH_FILE_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path relative to the current workspace."}, + "old_text": {"type": "string", "description": "Exact text to replace."}, + "new_text": {"type": "string", "description": "Replacement text."}, + }, + "required": ["path", "old_text", "new_text"], +} + class WorkspacePathError(ValueError): """Raised when a requested path escapes the configured workspace.""" @@ -158,6 +177,20 @@ def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tupl return root, resolved +def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]: + root = _workspace_root(workspace) + if not user_path or not str(user_path).strip(): + raise WorkspacePathError("path is required") + raw_path = Path(str(user_path)).expanduser() + candidate = raw_path if raw_path.is_absolute() else root / raw_path + parent = candidate.parent.resolve(strict=True) + try: + parent.relative_to(root) + except ValueError as exc: + raise WorkspacePathError(f"path escapes workspace: {user_path}") from exc + return root, parent / candidate.name + + def _relative_path(root: Path, path: Path) -> str: try: return str(path.relative_to(root)) or "." @@ -440,3 +473,73 @@ class SearchFilesTool: ) except (OSError, WorkspacePathError, ValueError) as exc: return _json_result(False, error=str(exc), path=path) + + +@dataclass(slots=True) +class WriteFileTool: + """Write a UTF-8 text file inside the current workspace.""" + + name: str = "write_file" + description: str = ( + "Write a UTF-8 text file inside the current workspace, replacing the full file. " + "Use patch_file for targeted edits. Paths outside the workspace are rejected." + ) + toolset: str = "filesystem" + always_available: bool = False + workspace: str | None = None + parameters: dict[str, Any] = field(default_factory=lambda: dict(WRITE_FILE_PARAMETERS)) + + async def execute(self, *, path: str, content: str, workspace: str | None = None) -> str: + try: + root, resolved = _resolve_writable_path(workspace, path) + resolved.parent.mkdir(parents=True, exist_ok=True) + resolved.write_text(str(content), encoding="utf-8") + return _json_result(True, path=_relative_path(root, resolved), bytes=len(str(content).encode("utf-8"))) + except (OSError, WorkspacePathError, ValueError) as exc: + return _json_result(False, error=str(exc), path=path) + + +@dataclass(slots=True) +class PatchFileTool: + """Replace an exact text fragment inside a workspace file.""" + + name: str = "patch_file" + description: str = ( + "Replace an exact text fragment inside a UTF-8 workspace file. " + "Fails if old_text is missing or ambiguous." + ) + toolset: str = "filesystem" + always_available: bool = False + workspace: str | None = None + parameters: dict[str, Any] = field(default_factory=lambda: dict(PATCH_FILE_PARAMETERS)) + + async def execute( + self, + *, + path: str, + old_text: str, + new_text: str, + workspace: str | None = None, + ) -> str: + try: + root, resolved = _resolve_existing_path(workspace, path) + if not resolved.is_file(): + return _json_result(False, error="not_a_file", path=path) + content = _read_text_file(resolved) + occurrences = content.count(old_text) + if occurrences == 0: + return _json_result(False, error="old_text_not_found", path=path) + if occurrences > 1: + return _json_result(False, error="old_text_ambiguous", occurrences=occurrences, path=path) + updated = content.replace(old_text, new_text, 1) + resolved.write_text(updated, encoding="utf-8") + return _json_result( + True, + path=_relative_path(root, resolved), + old_bytes=len(old_text.encode("utf-8")), + new_bytes=len(new_text.encode("utf-8")), + ) + except UnicodeDecodeError: + return _json_result(False, error="file is not valid UTF-8 text", path=path) + except (OSError, WorkspacePathError, ValueError) as exc: + return _json_result(False, error=str(exc), path=path) diff --git a/app-instance/backend/beaver/tools/builtins/skills_admin.py b/app-instance/backend/beaver/tools/builtins/skills_admin.py new file mode 100644 index 0000000..58013ea --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/skills_admin.py @@ -0,0 +1,87 @@ +"""Runtime tools for listing and managing skills.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any + +from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec + + +def _result(tool_name: str, success: bool, **payload: Any) -> ToolResult: + return ToolResult( + success=success, + tool_name=tool_name, + content=json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2), + error=None if success else str(payload.get("error") or "failed"), + ) + + +@dataclass(slots=True) +class SkillsListTool(BaseTool): + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="skills_list", + description="List available skills with descriptions.", + input_schema={"type": "object", "properties": {}}, + toolset="skills", + ) + + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + loader = context.get("skills_loader") + if loader is None: + return _result(self.spec.name, False, error="skills_loader is unavailable") + skills = [ + { + "name": record.name, + "description": record.description, + "source": record.source, + "version": record.version, + "tool_hints": list(record.tool_hints), + } + for record in loader.list_skills(filter_unavailable=False) + ] + return _result(self.spec.name, True, skills=skills) + + +@dataclass(slots=True) +class SkillManageTool(BaseTool): + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="skill_manage", + description="Create a new skill draft. Publishing still goes through the normal review/publish APIs.", + input_schema={ + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["create_draft"]}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["action", "name", "content"], + }, + toolset="skills", + ) + + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + if arguments.get("action") != "create_draft": + return _result(self.spec.name, False, error="only create_draft is supported") + draft_service = context.get("draft_service") + if draft_service is None: + return _result(self.spec.name, False, error="draft_service is unavailable") + name = str(arguments.get("name") or "").strip() + content = str(arguments.get("content") or "").strip() + if not name or not content: + return _result(self.spec.name, False, error="name and content are required") + draft = draft_service.create_new_skill_draft( + skill_name=name, + proposed_content=content, + proposed_frontmatter={"description": str(arguments.get("description") or name)}, + created_by=context.user_id or "agent", + reason="created by skill_manage tool", + trigger_session_id=context.session_id, + ) + return _result(self.spec.name, True, draft=draft.to_dict()) diff --git a/app-instance/backend/beaver/tools/builtins/terminal.py b/app-instance/backend/beaver/tools/builtins/terminal.py new file mode 100644 index 0000000..c23581b --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/terminal.py @@ -0,0 +1,213 @@ +"""Local terminal and background process tools.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +import json +from pathlib import Path +import sys +from typing import Any +from uuid import uuid4 + + +def _json_result(success: bool, **payload: Any) -> str: + return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2) + + +class BackgroundProcessStore: + def __init__(self) -> None: + self._processes: dict[str, asyncio.subprocess.Process] = {} + self._logs: dict[str, bytes] = {} + + async def start(self, command: str, cwd: str | None = None) -> str: + process_id = uuid4().hex[:12] + proc = await asyncio.create_subprocess_shell( + command, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + self._processes[process_id] = proc + self._logs[process_id] = b"" + asyncio.create_task(self._drain(process_id, proc)) + return process_id + + async def _drain(self, process_id: str, proc: asyncio.subprocess.Process) -> None: + if proc.stdout is None: + return + while True: + chunk = await proc.stdout.read(4096) + if not chunk: + break + self._logs[process_id] = (self._logs.get(process_id, b"") + chunk)[-200_000:] + + def list(self) -> list[dict[str, Any]]: + rows = [] + for process_id, proc in self._processes.items(): + rows.append({"process_id": process_id, "returncode": proc.returncode, "running": proc.returncode is None}) + return rows + + def log(self, process_id: str, limit: int = 12000) -> str: + return self._logs.get(process_id, b"")[-limit:].decode("utf-8", errors="replace") + + async def kill(self, process_id: str) -> bool: + proc = self._processes.get(process_id) + if proc is None: + return False + if proc.returncode is None: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return True + + +GLOBAL_PROCESS_STORE = BackgroundProcessStore() + + +def _workspace_cwd(workspace: str | None, working_dir: str | None) -> str | None: + if not workspace: + return None + root = Path(workspace).expanduser().resolve() + raw = Path(working_dir or ".").expanduser() + candidate = raw if raw.is_absolute() else root / raw + resolved = candidate.resolve() + resolved.relative_to(root) + return str(resolved) + + +@dataclass(slots=True) +class TerminalTool: + name: str = "terminal" + description: str = "Execute a shell command. Set background=true for long-running commands." + toolset: str = "terminal" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "command": {"type": "string"}, + "working_dir": {"type": "string", "default": "."}, + "timeout": {"type": "integer", "default": 60, "minimum": 1, "maximum": 600}, + "background": {"type": "boolean", "default": False}, + }, + "required": ["command"], + } + ) + + async def execute( + self, + *, + command: str, + working_dir: str | None = None, + timeout: int = 60, + background: bool = False, + workspace: str | None = None, + ) -> str: + try: + if not command.strip(): + raise ValueError("command is required") + cwd = _workspace_cwd(workspace, working_dir) + if background: + process_id = await GLOBAL_PROCESS_STORE.start(command, cwd=cwd) + return _json_result(True, process_id=process_id, background=True) + proc = await asyncio.create_subprocess_shell( + command, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(proc.communicate(), timeout=max(1, min(int(timeout or 60), 600))) + text = output.decode("utf-8", errors="replace") + return _json_result(True, returncode=proc.returncode, output=text[-50000:]) + except Exception as exc: + return _json_result(False, error=str(exc)) + + +@dataclass(slots=True) +class ProcessTool: + name: str = "process" + description: str = "Manage background processes started with terminal(background=true)." + toolset: str = "terminal" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["list", "log", "kill"]}, + "process_id": {"type": "string"}, + }, + "required": ["action"], + } + ) + + async def execute(self, *, action: str, process_id: str | None = None, **_: Any) -> str: + if action == "list": + return _json_result(True, processes=GLOBAL_PROCESS_STORE.list()) + if action == "log": + if not process_id: + return _json_result(False, error="process_id is required") + return _json_result(True, process_id=process_id, output=GLOBAL_PROCESS_STORE.log(process_id)) + if action == "kill": + if not process_id: + return _json_result(False, error="process_id is required") + return _json_result(await GLOBAL_PROCESS_STORE.kill(process_id), process_id=process_id) + return _json_result(False, error=f"unknown action: {action}") + + +@dataclass(slots=True) +class ExecuteCodeTool: + name: str = "execute_code" + description: str = "Execute small Python snippets locally without external APIs." + toolset: str = "terminal" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "language": {"type": "string", "enum": ["python"], "default": "python"}, + "code": {"type": "string"}, + "timeout": {"type": "integer", "default": 30, "minimum": 1, "maximum": 120}, + "working_dir": {"type": "string", "default": "."}, + }, + "required": ["code"], + } + ) + + async def execute( + self, + *, + code: str, + language: str = "python", + timeout: int = 30, + working_dir: str | None = None, + workspace: str | None = None, + ) -> str: + try: + if language != "python": + raise ValueError("Only python is supported") + cwd = _workspace_cwd(workspace, working_dir) + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-I", + "-", + cwd=cwd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for( + proc.communicate(code.encode("utf-8")), + timeout=max(1, min(int(timeout or 30), 120)), + ) + return _json_result( + True, + language="python", + returncode=proc.returncode, + output=output.decode("utf-8", errors="replace")[-50000:], + ) + except Exception as exc: + return _json_result(False, error=str(exc)) diff --git a/app-instance/backend/beaver/tools/builtins/utility.py b/app-instance/backend/beaver/tools/builtins/utility.py new file mode 100644 index 0000000..0d4e5bb --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/utility.py @@ -0,0 +1,137 @@ +"""Small local utility tools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from typing import Any + + +def _json_result(success: bool, **payload: Any) -> str: + return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2) + + +@dataclass(slots=True) +class TodoTool: + name: str = "todo" + description: str = "Manage a lightweight task list for the current session." + toolset: str = "planning" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "todos": {"type": "array", "items": {"type": "object"}}, + "merge": {"type": "boolean", "default": False}, + }, + } + ) + + async def execute(self, *, todos: list[dict[str, Any]] | None = None, merge: bool = False, **kwargs: Any) -> str: + metadata = kwargs.get("metadata") if isinstance(kwargs.get("metadata"), dict) else {} + current = list(metadata.get("todos") or []) + if todos is None: + return _json_result(True, todos=current) + next_todos = [dict(item) for item in todos if isinstance(item, dict)] + metadata["todos"] = [*current, *next_todos] if merge else next_todos + return _json_result(True, todos=metadata["todos"]) + + +@dataclass(slots=True) +class ClarifyTool: + name: str = "clarify" + description: str = "Ask the user for clarification by returning a structured question." + toolset: str = "planning" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "question": {"type": "string"}, + "choices": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["question"], + } + ) + + async def execute(self, *, question: str, choices: list[str] | None = None, **_: Any) -> str: + return _json_result(True, question=question, choices=[str(item) for item in (choices or [])]) + + +@dataclass(slots=True) +class SendMessageTool: + name: str = "send_message" + description: str = "Return a message payload for an external channel. Actual delivery is handled by configured services." + toolset: str = "messaging" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "target": {"type": "string"}, + "message": {"type": "string"}, + }, + "required": ["target", "message"], + } + ) + + async def execute(self, *, target: str, message: str, **_: Any) -> str: + return _json_result(True, target=target, message=message, delivered=False) + + +@dataclass(slots=True) +class DelegateTool: + name: str = "delegate" + description: str = "Create a structured delegation request for a sub-agent or teammate." + toolset: str = "coordination" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "task": {"type": "string"}, + "agent": {"type": "string"}, + "context": {"type": "object"}, + }, + "required": ["task"], + } + ) + + async def execute(self, *, task: str, agent: str | None = None, context: dict[str, Any] | None = None, **_: Any) -> str: + return _json_result( + True, + task=task, + agent=agent or "default", + context=dict(context or {}), + queued=False, + note="Delegation request recorded; runtime execution is handled by configured agent services.", + ) + + +@dataclass(slots=True) +class SpawnTool: + name: str = "spawn" + description: str = "Create a structured request to spawn a bounded subtask." + toolset: str = "coordination" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "task": {"type": "string"}, + "role": {"type": "string", "default": "worker"}, + "write_scope": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["task"], + } + ) + + async def execute(self, *, task: str, role: str = "worker", write_scope: list[str] | None = None, **_: Any) -> str: + return _json_result( + True, + task=task, + role=role, + write_scope=[str(item) for item in (write_scope or [])], + queued=False, + note="Spawn request recorded; runtime execution is handled by configured agent services.", + ) diff --git a/app-instance/backend/beaver/tools/builtins/web.py b/app-instance/backend/beaver/tools/builtins/web.py new file mode 100644 index 0000000..cd37ddf --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/web.py @@ -0,0 +1,117 @@ +"""No-key web search and fetch tools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from html import unescape +import json +import re +from typing import Any +from urllib.parse import quote_plus, urlparse + +import httpx + + +def _json_result(success: bool, **payload: Any) -> str: + return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2) + + +def _strip_html(value: str) -> str: + text = re.sub(r"(?is)<(script|style).*?>.*?", " ", value) + text = re.sub(r"(?s)<[^>]+>", " ", text) + text = unescape(text) + return re.sub(r"\s+", " ", text).strip() + + +def _safe_url(url: str) -> str: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("url must be an http(s) URL") + return url + + +@dataclass(slots=True) +class WebFetchTool: + name: str = "web_fetch" + description: str = "Fetch a public HTTP(S) page and return readable text. No API key required." + toolset: str = "web" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "url": {"type": "string", "description": "HTTP(S) URL to fetch."}, + "max_chars": {"type": "integer", "default": 12000, "minimum": 1000, "maximum": 50000}, + }, + "required": ["url"], + } + ) + + async def execute(self, *, url: str, max_chars: int = 12000, **_: Any) -> str: + try: + safe_url = _safe_url(url) + limit = max(1000, min(int(max_chars or 12000), 50000)) + async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client: + response = await client.get( + safe_url, + headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"}, + ) + response.raise_for_status() + content_type = response.headers.get("content-type", "") + raw = response.text + text = _strip_html(raw) if "html" in content_type.lower() else raw + truncated = len(text) > limit + return _json_result( + True, + url=str(response.url), + status_code=response.status_code, + content_type=content_type, + content=text[:limit], + truncated=truncated, + ) + except Exception as exc: + return _json_result(False, url=url, error=str(exc)) + + +@dataclass(slots=True) +class WebSearchTool: + name: str = "web_search" + description: str = "Search the web using DuckDuckGo HTML results. No API key required." + toolset: str = "web" + always_available: bool = False + parameters: dict[str, Any] = field( + default_factory=lambda: { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query."}, + "limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 10}, + }, + "required": ["query"], + } + ) + + async def execute(self, *, query: str, limit: int = 5, **_: Any) -> str: + try: + if not str(query).strip(): + raise ValueError("query is required") + bounded = max(1, min(int(limit or 5), 10)) + url = f"https://duckduckgo.com/html/?q={quote_plus(query)}" + async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client: + response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"}) + response.raise_for_status() + html = response.text + results: list[dict[str, str]] = [] + pattern = re.compile( + r']+class="result__a"[^>]+href="(?P[^"]+)"[^>]*>(?P.*?)</a>', + re.I | re.S, + ) + for match in pattern.finditer(html): + title = _strip_html(match.group("title")) + result_url = unescape(match.group("url")) + if title and result_url: + results.append({"title": title, "url": result_url, "snippet": ""}) + if len(results) >= bounded: + break + return _json_result(True, query=query, results=results) + except Exception as exc: + return _json_result(False, query=query, error=str(exc)) diff --git a/app-instance/backend/beaver/tools/mcp/__init__.py b/app-instance/backend/beaver/tools/mcp/__init__.py index 0fd7c34..3385f19 100644 --- a/app-instance/backend/beaver/tools/mcp/__init__.py +++ b/app-instance/backend/beaver/tools/mcp/__init__.py @@ -1,2 +1,5 @@ """MCP-backed tool integrations.""" +from .wrapper import MCPToolWrapper + +__all__ = ["MCPToolWrapper"] diff --git a/app-instance/backend/beaver/tools/mcp/wrapper.py b/app-instance/backend/beaver/tools/mcp/wrapper.py new file mode 100644 index 0000000..59c2739 --- /dev/null +++ b/app-instance/backend/beaver/tools/mcp/wrapper.py @@ -0,0 +1,88 @@ +"""MCP tool wrappers for Beaver's tool contract.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import json +from typing import Any, Awaitable, Callable + +from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec + + +def _tool_schema(tool_def: Any) -> dict[str, Any]: + schema = getattr(tool_def, "inputSchema", None) or getattr(tool_def, "input_schema", None) + if isinstance(schema, dict): + return schema + return {"type": "object", "properties": {}} + + +def _tool_name(tool_def: Any) -> str: + return str(getattr(tool_def, "name", "") or "") + + +def _tool_description(tool_def: Any) -> str: + return str(getattr(tool_def, "description", "") or _tool_name(tool_def)) + + +def _mcp_result_to_text(result: Any) -> str: + parts: list[str] = [] + for block in list(getattr(result, "content", []) or []): + text = getattr(block, "text", None) + parts.append(str(text if text is not None else block)) + if not parts and getattr(result, "structuredContent", None) is not None: + return json.dumps(getattr(result, "structuredContent"), ensure_ascii=False, indent=2) + return "\n".join(parts) or "(no output)" + + +@dataclass(slots=True) +class MCPToolWrapper(BaseTool): + server_id: str + tool_def: Any + call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]] + tool_timeout: int = 30 + sensitive: bool = False + kind: str = "online" + category: str = "online" + display_name: str = "" + + @property + def original_name(self) -> str: + return _tool_name(self.tool_def) + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name=f"mcp_{self.server_id}_{self.original_name}", + description=_tool_description(self.tool_def), + input_schema=_tool_schema(self.tool_def), + toolset=f"mcp-{self.server_id}", + metadata={ + "server_id": self.server_id, + "original_tool_name": self.original_name, + "kind": self.kind, + "category": self.category, + "display_name": self.display_name or self.server_id, + "transport": "mcp", + }, + ) + + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + try: + result = await asyncio.wait_for( + self.call_tool(self.original_name, dict(arguments or {})), + timeout=max(1, int(self.tool_timeout or 30)), + ) + return ToolResult( + success=True, + content=_mcp_result_to_text(result), + tool_name=self.spec.name, + raw_output=result, + ) + except Exception as exc: + return ToolResult( + success=False, + content=f"MCP tool {self.server_id}.{self.original_name} failed: {exc}", + tool_name=self.spec.name, + error=str(exc), + ) diff --git a/app-instance/backend/change.md b/app-instance/backend/change.md index 18256aa..2db5086 100644 --- a/app-instance/backend/change.md +++ b/app-instance/backend/change.md @@ -74,7 +74,7 @@ 4. Agent Team 已融入 Task mode 内部执行策略。 - `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`。 - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。 - - `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用。 + - `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用。 - team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。 - Team 输出只作为主 Agent synthesis run 的内部上下文。 - 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。 @@ -914,15 +914,15 @@ app-instance/backend/ - sub-agent 是临时 generic worker,不承载固定角色人设。 - `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。 - `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。 - - 如果没有命中 published skill,会创建 draft-only skill,并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。 - - draft 不自动 approve/publish,不进入 runtime catalog;后续仍走 review/publish。 + - 如果没有命中 published skill,会创建 ephemeral guidance,并作为本次 sub-agent 的 pinned skill context 使用。 + - ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime catalog。 - agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。 2. **Task Team Process Projection** - - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed`。 + - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report / node_results / task_synthesis_completed`。 - 新增 `GET /api/sessions/{session_id}/process`。 - 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。 - - 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。 + - 展示规划、skill selection、ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。 3. **Learning Pipeline 闭环** - 新增 `SkillLearningPipelineService`。 diff --git a/app-instance/backend/flow.md b/app-instance/backend/flow.md index 53d2ca0..5cdfeca 100644 --- a/app-instance/backend/flow.md +++ b/app-instance/backend/flow.md @@ -18,7 +18,7 @@ └─ future channels(未来扩展入口) │ └─ AgentService(统一服务层:所有入口都先汇总到这里) - ├─ MainAgentRouter(自动判断 simple / task) +├─ MainAgentRouter(LLM 语义判断 simple / continue task / new task / close / abandon) ├─ create_loop()(创建 AgentLoop 运行核心) ├─ start()(启动后台运行模式) ├─ submit_direct()(把任务提交到运行队列) @@ -73,15 +73,20 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层 │ ├─ resolve session_id(复用请求 session,或生成新 session) ├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task) -├─ MainAgentRouter.classify(message, active_task)(自动分类) +├─ MainAgentRouter.classify(message, active_task, recent_messages)(LLM 语义分类) │ ├─ simple(简单问题) -│ │ └─ runner(message)(直接走原有 AgentLoop,不创建 Task) +│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools) │ │ -│ └─ task(复杂任务) -│ ├─ if no active task or user starts new task -│ │ └─ TaskService.create_task(...)(内部创建 Task) -│ ├─ else -│ │ └─ reuse active Task(复用 awaiting_feedback / needs_revision Task) +│ ├─ continue_task(继续当前 Task) +│ │ └─ reuse active Task(只要话题没有完全无关,就继续当前 open Task) +│ │ +│ ├─ new_task(明确开启新任务) +│ │ └─ TaskService.create_task(...)(内部创建 Task,并保存 short_title) +│ │ +│ ├─ close_task / abandon_task(用户明确结束或放弃) +│ │ └─ TaskService.close_task / abandon_task(关闭当前 Task) +│ │ +│ └─ task execution │ └─ AgentService._run_task_mode(...)(进入 Task 模式执行) ``` @@ -92,6 +97,7 @@ TaskService(内部 Task 状态机) │ ├─ task_id │ ├─ session_id │ ├─ goal / description / constraints +│ ├─ metadata.short_title(5-15 字左右的短标题,用于前端当前任务标识) │ ├─ status │ │ ├─ open │ │ ├─ running @@ -167,17 +173,32 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务) │ ├─ auxiliary_provider(辅助模型调用器,用于选 skill 等) │ └─ embedding_runtime(向量模型配置,用于语义召回) │ -├─ skill_assembler.assemble(...)(选择本轮应该激活哪些 skill) -│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要) -│ ├─ embedding retrieve skill candidates(用向量召回相关技能) -│ ├─ LLM select activated skills(让模型从候选里选择技能) -│ └─ 返回 activated skills(返回本轮被激活的技能) -│ ├─ name(技能名称) -│ ├─ content(技能正文) -│ ├─ version(技能版本) -│ ├─ content_hash(技能内容哈希,用于追踪) -│ ├─ activation_reason(为什么激活) -│ └─ tool_hints(技能建议使用哪些工具) +├─ if include_skill_assembly=False(simple_chat 默认关闭) +│ └─ skip SkillAssembler(不激活 skill,不注入 skill 正文) +│ +├─ if include_skill_assembly=True(Task mode 默认开启,在 Task 创建/复用和规划之后执行) +│ └─ skill_assembler.assemble(...)(选择本轮应该激活哪些 published skill) +│ ├─ input task_description = skill_selection_context or current user input +│ │ ├─ Task goal / description +│ │ ├─ current user request +│ │ ├─ attempt / revision / team synthesis phase +│ │ ├─ validation feedback(重试时) +│ │ ├─ team summary / plan(team synthesis 时) +│ │ └─ previously activated skills(只作为 reuse bias,不是 pinned) +│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要) +│ ├─ embedding retrieve skill candidates(用向量召回相关技能) +│ ├─ LLM shortlist candidate names(先用摘要粗选少量候选) +│ │ └─ if retrieved candidates <= max_detailed_candidates -> skip shortlist +│ ├─ SkillsLoader.load_published_skill(...)(系统侧内部读取粗选候选正文,不暴露 skill_view 给主 Agent) +│ ├─ LLM final select activated skills(结合候选正文做最终选择) +│ ├─ if no matching skill -> return [] and continue run without skills +│ └─ 返回 activated skills(返回本轮被激活的技能) +│ ├─ name(技能名称) +│ ├─ content(技能正文) +│ ├─ version(技能版本) +│ ├─ content_hash(技能内容哈希,用于追踪) +│ ├─ activation_reason(为什么激活) +│ └─ tool_hints(技能建议使用哪些工具) │ ├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息) ├─ 构造 SkillActivationReceipt[](构造技能激活收据) @@ -188,7 +209,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务) │ ├─ receipts(技能激活收据) │ └─ activation_messages(实际注入给模型的技能消息) │ -├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具) +├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具;simple_chat 默认跳过) │ ├─ always tools(默认总是可用的工具) │ ├─ activated skill tool hints(被激活技能推荐的工具) │ ├─ embedding retrieve tools(用向量召回相关工具) @@ -207,6 +228,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务) │ └─ append current user input(追加当前用户输入) │ ├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话) +├─ session_manager.append_message(event_type="skill_selection_context_snapshotted", hidden)(完整记录 skill query) ├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)(记录隐藏事件:system prompt 快照) ├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息) │ @@ -214,12 +236,12 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务) │ ├─ 成功时(模型正常结束) │ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成) -│ └─ _record_skill_learning(...)(记录技能使用效果,进入学习闭环) +│ └─ _record_run_receipts(...)(记录运行证据,不生成学习候选) │ ├─ 失败时(运行中出现异常) │ ├─ append assistant error message(写入 assistant 错误消息) │ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败) -│ └─ _record_skill_learning(...)(即使失败也记录技能效果) +│ └─ _record_run_receipts(...)(即使失败也记录运行证据) │ └─ return AgentRunResult(返回本轮结果) ├─ session_id(会话编号) @@ -242,6 +264,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务) ```text tool loop(工具调用循环) │ +├─ session_manager.append_message(event_type="llm_request_snapshotted", hidden)(完整记录本次 provider messages / tools) ├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型) ├─ session_manager.update_usage(...)(累计 token 用量) ├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复) @@ -259,10 +282,10 @@ tool loop(工具调用循环) --- -## 6. Skills Learning Baseline +## 6. Run Evidence / Skill Effect Recording ```text -AgentLoop._record_skill_learning(...)(记录本轮技能效果) +AgentLoop._record_run_receipts(...)(记录本轮运行证据;不直接学习) │ ├─ 构造 RunRecord(构造本轮运行记录) │ ├─ run_id(运行编号) @@ -286,11 +309,7 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果) │ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl) │ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现) │ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照) -│ └─ optionally build learning candidates(默认不生成;只由反馈门控显式触发) -│ ├─ revise_skill(建议修改已有技能) -│ ├─ new_skill(建议创建新技能) -│ ├─ merge_skills(建议合并相似技能) -│ └─ retire_skill(建议退役长期不用的技能) +│ └─ never build learning candidates in runtime hot path(运行完成时永不生成候选) │ └─ session_manager.append_message(...)(记录隐藏事件:技能效果快照) ├─ event_type="skill_effects_snapshotted"(技能效果已快照) @@ -298,10 +317,23 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果) └─ payload(隐藏数据) ├─ run_record(本轮运行记录) ├─ skill_effects(技能效果记录) - ├─ learning_candidate_enabled(本轮是否允许生成候选,默认 false) + ├─ candidate_generation_allowed(本轮是否允许生成候选;runtime 固定 false) └─ learning_candidates(学习候选;默认空) ``` +```text +runtime invariant(运行期不直接学习) +│ +├─ run completed / run failed +│ └─ 只写 RunRecord + SkillEffectRecord + performance snapshot +│ +├─ simple chat +│ └─ 不创建 Task,不触发 learning candidate +│ +└─ Task attempt / sub-agent run + └─ 只留下证据,等待 feedback gate 决定是否学习 +``` + --- ## 7. Chat Feedback / Learning Gate @@ -328,17 +360,20 @@ POST /api/chat/feedback(聊天反馈接口,不是 Task 管理 API) ├─ satisfied │ ├─ if validation accepted │ │ ├─ Task status -> closed -│ │ └─ SkillLearningService.build_learning_candidates() +│ │ └─ SkillLearningService.build_learning_candidates_for_task(task_id, trigger_run_id) │ └─ if validation not accepted -│ └─ 记录人工接受但保留验证风险 +│ └─ 记录人工接受但保留验证风险;不自动生成 learning candidate │ ├─ revise │ ├─ Task status -> needs_revision -│ └─ 下一条用户消息默认复用该 Task +│ ├─ 更新 run / skill effect 为需修订证据 +│ └─ 下一条用户消息默认复用该 Task;不生成 learning candidate │ └─ abandon ├─ Task status -> abandoned - └─ write Failure Memory(不生成成功 Skill draft) + ├─ 更新 run / skill effect 为失败证据 + ├─ 追加 task_failure_evidence_recorded 隐藏事件 + └─ 默认不写主 memory,不生成成功 Skill draft ``` --- @@ -356,15 +391,15 @@ TeamService.run_team(...)(内部 team 执行入口,不暴露产品级 Task A │ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router │ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle) │ ├─ inherited_pinned_skills(主 agent 明确委派给 sub-agent 的 pinned skills) -│ ├─ inherited_pinned_skill_contexts(missing skill draft 生成的 ephemeral skill guidance) -│ └─ learning_candidate_enabled=False(默认只写 receipts,不绕过 Task feedback gate) +│ ├─ inherited_pinned_skill_contexts(missing skill 生成的一次性 ephemeral guidance) +│ └─ allow_candidate_generation=False(默认只写 receipts,不绕过 Task feedback gate) │ ├─ LocalAgentRunner.run(envelope) │ ├─ 生成 child_session_id │ ├─ parent_session_id -> 主 session(建立 session lineage) │ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService) │ ├─ pinned_skill_names -> AgentLoop(published pinned skill 必须注入) -│ ├─ pinned_skill_contexts -> AgentLoop(draft-only ephemeral skill 必须注入) +│ ├─ pinned_skill_contexts -> AgentLoop(ephemeral guidance 只在本次 run 注入) │ └─ provider_bundle + node model/provider override 禁止混用 │ ├─ strategy execution @@ -522,7 +557,6 @@ SkillsLoader(技能加载器) ├─ build_skills_summary()(构造技能摘要索引) ├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要) ├─ list_skill_supporting_files()(列出技能支持文件) -├─ view_skill()(查看技能正文或支持文件) └─ get_always_skills()(获取 always 类型技能) ``` @@ -530,13 +564,17 @@ SkillsLoader(技能加载器) SkillAssembler(技能选择器) │ ├─ input(输入) -│ ├─ task_description(用户任务描述) +│ ├─ task_description(Task-aware query:Task 描述 / 当前用户消息 / previous skills / attempt context / validation revision context / team context) │ ├─ candidate skill summaries(候选技能摘要) │ ├─ embedding runtime(向量模型配置) │ └─ selector provider/model(用于选择技能的模型) │ ├─ embedding retrieve candidates(先用向量召回相关技能) -├─ LLM select names(再让 LLM 选择技能名) +├─ LLM shortlist names(用摘要粗选需要查看正文的候选) +│ └─ skip when candidate count <= max_detailed_candidates(候选很少时直接读取正文) +├─ internal load shortlisted SKILL.md(SkillAssembler 内部读取候选正文) +├─ LLM final select names(结合候选正文选择最终技能名) +├─ no match returns [](没有对应 published skill 时返回空,不阻塞任务) └─ return SkillContext[](返回技能上下文) ├─ name(技能名) ├─ content(技能正文) @@ -586,7 +624,6 @@ ToolRegistry(工具注册表) │ ├─ echo(回显工具) ├─ memory(写入/管理长期记忆) -├─ skill_view(查看完整 skill) ├─ session_search(搜索会话历史) ├─ list_directory(列目录) ├─ read_file(读文件) @@ -599,7 +636,7 @@ ToolAssembler(工具选择器) ├─ selected = always tools(先加入默认工具) ├─ selected += activated skill tool hints(再加入技能推荐工具) ├─ selected += embedding top-k tools(再用向量召回任务相关工具) -└─ return ToolSpec[](返回本轮可用工具列表) +└─ return ToolSpec[](返回本轮可用工具列表;不通过工具动态加载 skill) ``` ```text @@ -703,11 +740,11 @@ TaskExecutionPlanner(Task 内部执行规划) │ ├─ 从 published skill catalog 检索候选 │ ├─ 按 skill_query / required_capabilities / node task 选择 skill │ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills -│ └─ 无命中时创建 draft-only skill,并写入 graph.nodes[].inherited_pinned_skill_contexts +│ └─ 无命中时创建 ephemeral guidance,并写入 graph.nodes[].inherited_pinned_skill_contexts │ └─ TaskExecutionPlan ├─ graph.nodes[].agent 只是 generic runtime trace identity - └─ to_event_payload() 写入 skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report + └─ to_event_payload() 写入 skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report ``` ```text @@ -748,8 +785,21 @@ Frontend process projection ```text Learning pipeline │ +├─ evidence recording +│ ├─ every run -> RunRecord +│ ├─ activated skills -> SkillEffectRecord +│ └─ no candidates generated here +│ ├─ feedback gate -│ └─ validation accepted + satisfied 才生成 learning candidate +│ ├─ validation accepted + satisfied -> scoped learning candidate +│ ├─ validation rejected + satisfied -> 记录人工接受风险,不生成候选 +│ ├─ revise -> 保持 Task 打开,不生成候选 +│ └─ abandon -> 失败证据,不写主 memory,不生成成功候选 +│ +├─ scoped candidate generation +│ ├─ source = current task run_ids +│ ├─ no published skill -> new_skill +│ └─ published skill used -> revise_skill │ ├─ SkillLearningPipelineService │ ├─ candidate -> queued / synthesizing @@ -799,6 +849,12 @@ Web(网页入口) │ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService) │ └─ return WebChatResponse(返回模型回复 + run/task/validation 元数据) │ +├─ WS /ws/{session_id}(网页 WebSocket 适配层) +│ ├─ ping -> pong +│ ├─ message -> agent_service.submit_direct(...) +│ ├─ return status / assistant message(携带 run/task/validation 元数据) +│ └─ return session_updated(通知前端刷新 session/process) +│ └─ POST /api/chat/feedback(聊天反馈接口) ├─ validate WebChatFeedbackRequest ├─ agent_service.submit_feedback(...) @@ -820,8 +876,10 @@ Skills learning admin API Gateway(消息通道入口) │ ├─ MessageBus(内部消息总线) +├─ ChannelAdapter(Telegram / Slack / Email / WhatsApp 等只作为 adapter) ├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService) -└─ outbound <- OutboundMessage(AgentService 返回结构化输出消息) +├─ outbound <- OutboundMessage(AgentService 返回结构化输出消息) +└─ ChannelManager(按 message.channel 分发 outbound) ``` --- diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml index 3274382..4abada7 100644 --- a/app-instance/backend/pyproject.toml +++ b/app-instance/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "Beaver backend skeleton" requires-python = ">=3.11" dependencies = [ "anthropic>=0.51.0,<1.0.0", + "croniter>=6.0.0,<7.0.0", "fastmcp>=3.0.0,<4.0.0", "fastapi>=0.115.0,<1.0.0", "httpx>=0.28.0,<1.0.0", @@ -12,6 +13,7 @@ dependencies = [ "litellm>=1.79.0,<2.0.0", "openai>=1.79.0,<2.0.0", "pydantic>=2.12.0,<3.0.0", + "python-multipart>=0.0.20,<1.0.0", "typer>=0.20.0,<1.0.0", "uvicorn[standard]>=0.34.0,<1.0.0", ] diff --git a/app-instance/backend/tests/unit/test_active_task_api.py b/app-instance/backend/tests/unit/test_active_task_api.py new file mode 100644 index 0000000..1bc7c5a --- /dev/null +++ b/app-instance/backend/tests/unit/test_active_task_api.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_active_task_api_returns_open_task_and_hides_closed(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + loaded = service.create_loop().boot() + task = loaded.task_service.create_task( # type: ignore[union-attr] + session_id="web:active", + description="实现任务连续性", + metadata={"short_title": "任务连续性"}, + ) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + active = client.get("/api/sessions/web:active/active-task") + listed = client.get("/api/tasks") + loaded.task_service.close_task(task.task_id, reason="done") # type: ignore[union-attr] + inactive = client.get("/api/sessions/web:active/active-task") + + assert active.status_code == 200 + assert active.json()["task_id"] == task.task_id + assert active.json()["short_title"] == "任务连续性" + assert listed.json()[0]["short_title"] == "任务连续性" + assert inactive.status_code == 200 + assert inactive.json() is None + + +def test_active_task_api_hides_unengaged_cron_task(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + loaded = service.create_loop().boot() + hidden = loaded.task_service.create_task( # type: ignore[union-attr] + session_id="web:cron", + description="提醒用户喝水", + creator="cron", + metadata={"source": "scheduled_cron", "user_engaged": False}, + ) + visible = loaded.task_service.create_task( # type: ignore[union-attr] + session_id="web:engaged", + description="修改新闻总结", + creator="cron", + metadata={"source": "scheduled_run", "user_engaged": True}, + ) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + hidden_response = client.get("/api/sessions/web:cron/active-task") + visible_response = client.get("/api/sessions/web:engaged/active-task") + + assert hidden_response.status_code == 200 + assert hidden_response.json() is None + assert visible_response.status_code == 200 + assert visible_response.json()["task_id"] == visible.task_id + assert hidden.task_id != visible.task_id + + +def test_task_delete_api_removes_backend_task(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + loaded = service.create_loop().boot() + task = loaded.task_service.create_task( # type: ignore[union-attr] + session_id="web:delete", + description="删除这个任务", + ) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + deleted = client.delete(f"/api/tasks/{task.task_id}") + listed = client.get("/api/tasks") + missing = client.get(f"/api/tasks/{task.task_id}") + + assert deleted.status_code == 200 + assert deleted.json()["task_id"] == task.task_id + assert all(item["task_id"] != task.task_id for item in listed.json()) + assert missing.status_code == 404 diff --git a/app-instance/backend/tests/unit/test_agent_team_v1.py b/app-instance/backend/tests/unit/test_agent_team_v1.py index c0ec300..bed4579 100644 --- a/app-instance/backend/tests/unit/test_agent_team_v1.py +++ b/app-instance/backend/tests/unit/test_agent_team_v1.py @@ -59,7 +59,7 @@ class BlockingSkillAssembler: self.release_first = asyncio.Event() async def assemble(self, **kwargs) -> SkillAssemblyResult: - if kwargs["task_description"] == "task first": + if "task first" in kwargs["task_description"]: self.first_started.set() await self.release_first.wait() return SkillAssemblyResult() diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index 7840cde..c353f1c 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from beaver.engine import AgentLoop, EngineLoader from beaver.engine.providers import make_provider_bundle @@ -42,6 +43,37 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None: assert target["extra_headers"] == {"X-Test": "1"} +def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": { + "defaults": { + "workspace": str(tmp_path / "workspace"), + "model": "qwen-plus", + "provider": "custom", + } + }, + "providers": { + "custom": {}, + "openai": { + "apiKey": "sk-test", + "apiBase": "https://oai.example.com/v1", + }, + }, + } + ), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + assert config.resolve_provider_target()["provider_name"] == "openai" + assert config.resolve_provider_target(provider_name="custom")["provider_name"] == "openai" + assert config.resolve_provider_target(provider_name="deepseek")["provider_name"] == "openai" + + def test_engine_loader_uses_config_workspace(tmp_path) -> None: workspace = tmp_path / "workspace" config_path = tmp_path / "config.json" @@ -105,3 +137,40 @@ def test_openai_compatible_qwen_config_keeps_openai_provider() -> None: assert bundle.main_runtime.api_base == "https://oai.example.com/v1" assert isinstance(bundle.main_provider, LiteLLMProvider) assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus" + + +def test_load_config_reads_stevenli_mcp_authz_identity() -> None: + repo_root = Path(__file__).resolve().parents[4] + config_path = repo_root / "app-instance" / "runtime" / "instances" / "stevenli" / "nanobot-home" / "config.json" + config = load_config(config_path=config_path) + + server = config.tools.mcp_servers["outlook_mcp"] + assert server.transport == "http" + assert server.url == "http://10.6.80.29:8000/mcp" + assert server.auth_mode == "oauth_backend_token" + assert server.auth_audience == "mcp:outlook_mcp" + assert "tool:mail_list_messages" in server.auth_scopes + assert server.tool_timeout == 60 + assert server.sensitive is True + + assert config.authz.enabled is True + assert config.authz.base_url == "http://nano-authz-service:19090" + assert config.backend_identity.backend_id == "stevenli" + assert config.backend_identity.client_id == "stevenli" + + +def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"tools": {"mcpServers": {}}}), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + local = config.tools.mcp_servers["local_filesystem_mcp"] + assert local.transport == "stdio" + assert local.kind == "local" + assert local.category == "filesystem" + assert local.managed is True + assert "beaver.interfaces.mcp.tools_server" in local.args diff --git a/app-instance/backend/tests/unit/test_cron_service.py b/app-instance/backend/tests/unit/test_cron_service.py new file mode 100644 index 0000000..7584d69 --- /dev/null +++ b/app-instance/backend/tests/unit/test_cron_service.py @@ -0,0 +1,126 @@ +import asyncio + +from beaver.foundation.models import CronExecutionResult, CronRunRecord, CronSchedule +from beaver.tools.base import ToolContext +from beaver.tools.builtins import CronTool +from beaver.services.cron_service import CronService, compute_next_run, parse_schedule, schedule_from_api + + +def test_parse_hermes_style_schedules() -> None: + interval = parse_schedule("every 15m") + assert interval.kind == "every" + assert interval.every_ms == 15 * 60 * 1000 + + one_shot = parse_schedule("30s") + assert one_shot.kind == "at" + assert one_shot.at_ms is not None + + cron = parse_schedule("0 9 * * *") + assert cron.kind == "cron" + assert cron.expr == "0 9 * * *" + + +def test_schedule_from_frontend_payload() -> None: + every = schedule_from_api({"every_seconds": 60}) + assert every.kind == "every" + assert every.every_ms == 60_000 + + cron = schedule_from_api({"cron_expr": "0 10 * * *"}) + assert cron.kind == "cron" + + +def test_compute_next_run_skips_missed_interval() -> None: + schedule = CronSchedule(kind="every", every_ms=60_000) + assert compute_next_run(schedule, now_ms=1_000_000, last_run_at_ms=0) > 1_000_000 + + +def test_manual_run_records_task_history(tmp_path) -> None: + async def on_job(job): + return CronExecutionResult(response="done", task_id=f"task-{job.id}", run_id="run-1") + + service = CronService(tmp_path / "jobs.json", on_job=on_job) + job = service.add_job( + name="Daily check", + message="Check the project", + schedule=CronSchedule(kind="every", every_ms=3600_000), + session_key="web:default", + ) + + assert asyncio.run(service.run_job(job.id, force=True)) is True + updated = service.get_job(job.id) + assert updated is not None + assert updated.last_status == "ok" + assert updated.history[-1].task_id == f"task-{job.id}" + assert updated.to_api_dict()["last_task_id"] == f"task-{job.id}" + + +def test_manual_run_records_scheduled_run_output(tmp_path) -> None: + async def on_job(job, run): + return CronExecutionResult( + response=f"notification for {run.scheduled_run_id}", + run_id="run-notify", + notification_session_id="notify:default:scheduled", + mode="notification", + ) + + service = CronService(tmp_path / "jobs.json", on_job=on_job) + job = service.add_job( + name="Daily news", + message="Summarize news", + schedule=CronSchedule(kind="every", every_ms=3600_000), + ) + + assert asyncio.run(service.run_job(job.id, force=True)) is True + updated = service.get_job(job.id) + assert updated is not None + run = updated.history[-1] + assert run.scheduled_run_id + assert run.output == f"notification for {run.scheduled_run_id}" + assert run.notification_session_id == "notify:default:scheduled" + assert updated.to_api_dict()["last_scheduled_run_id"] == run.scheduled_run_id + + +def test_cron_tool_uses_runtime_service(tmp_path) -> None: + service = CronService(tmp_path / "jobs.json") + tool = CronTool() + result = asyncio.run( + tool.invoke( + { + "action": "add", + "name": "Tool-created task", + "message": "Check the queue", + "every_seconds": 300, + }, + ToolContext(session_id="session-1", services={"cron_service": service}), + ) + ) + + assert result.success is True + jobs = service.list_jobs(include_disabled=True) + assert len(jobs) == 1 + assert jobs[0].payload.session_key == "session-1" + + +def test_mark_run_engaged_links_task(tmp_path) -> None: + service = CronService(tmp_path / "jobs.json") + job = service.add_job( + name="Daily news", + message="Summarize news", + schedule=CronSchedule(kind="every", every_ms=3600_000), + ) + run = CronRunRecord( + started_at_ms=1, + status="ok", + output="news summary", + notification_session_id="notify:default:scheduled", + ) + job.history.append(run) + service._save_jobs() + + linked = service.mark_run_engaged(run.scheduled_run_id, task_id="task-1", intent="revise_once") + + assert linked is not None + updated = service.get_run(run.scheduled_run_id) + assert updated is not None + assert updated[1].engaged is True + assert updated[1].task_id == "task-1" diff --git a/app-instance/backend/tests/unit/test_debug_chat_logs_api.py b/app-instance/backend/tests/unit/test_debug_chat_logs_api.py new file mode 100644 index 0000000..9d939fa --- /dev/null +++ b/app-instance/backend/tests/unit/test_debug_chat_logs_api.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_debug_chat_logs_group_events_by_run(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + loaded = service.create_loop().boot() + manager = loaded.session_manager + session_id = "web:debug" + run_id = "run-debug" + manager.ensure_session(session_id, source="web", title="Debug") + manager.append_message( + session_id, + run_id=run_id, + role="system", + event_type="run_started", + event_payload={"source": "web", "task_id": "task-1", "attempt_index": 1}, + content="hello", + context_visible=False, + ) + manager.append_message( + session_id, + run_id=run_id, + role="system", + event_type="llm_request_snapshotted", + event_payload={"messages": [{"role": "user", "content": "hello"}], "tools": []}, + content='{"messages":[{"role":"user","content":"hello"}],"tools":[]}', + context_visible=False, + ) + manager.append_message( + session_id, + run_id=run_id, + role="user", + event_type="user_message_added", + content="hello", + ) + manager.append_message( + session_id, + run_id=run_id, + role="assistant", + event_type="assistant_message_added", + content="hi", + finish_reason="stop", + ) + + app = create_app(service=service, manage_service_lifecycle=False) + with TestClient(app) as client: + response = client.get("/api/debug/chat-logs") + + assert response.status_code == 200 + sessions = response.json()["sessions"] + run = sessions[0]["runs"][0] + assert run["run_id"] == run_id + assert run["user_input"] == "hello" + assert [event["event_type"] for event in run["events"]] == [ + "run_started", + "llm_request_snapshotted", + "user_message_added", + "assistant_message_added", + ] + assert run["events"][1]["event_payload"]["messages"][0]["content"] == "hello" diff --git a/app-instance/backend/tests/unit/test_gateway_channels.py b/app-instance/backend/tests/unit/test_gateway_channels.py index 3f2a104..67cd739 100644 --- a/app-instance/backend/tests/unit/test_gateway_channels.py +++ b/app-instance/backend/tests/unit/test_gateway_channels.py @@ -17,6 +17,9 @@ class FakeResult: provider_name: str | None = "fake" model: str | None = "fake-model" usage: dict[str, Any] = field(default_factory=dict) + task_id: str | None = "task-1" + task_status: str | None = "awaiting_feedback" + validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True}) class FakeService: @@ -75,6 +78,9 @@ def test_gateway_routes_memory_channel_roundtrip() -> None: assert message.content == "echo:hello" assert message.session_id == "s1" assert message.finish_reason == "stop" + assert message.metadata["task_id"] == "task-1" + assert message.metadata["task_status"] == "awaiting_feedback" + assert message.metadata["validation_result"] == {"accepted": True} stop_event.set() await asyncio.wait_for(task, timeout=2) @@ -183,6 +189,50 @@ def test_agent_service_maps_stopped_runtime_to_stopped_outbound() -> None: asyncio.run(run()) +def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None: + async def run() -> None: + bus = MessageBus() + manager = ChannelManager(bus) + stop_event = asyncio.Event() + await bus.publish_outbound( + AgentService.build_outbound_message( + InboundMessage(channel="missing", content="hello", session_id="missing:1"), + FakeResult(session_id="missing:1", output_text="ok"), + ) + ) + stop_event.set() + + await manager.dispatch_outbound(stop_event) + + assert len(manager.undeliverable) == 1 + assert manager.undeliverable[0].channel == "missing" + assert manager.undeliverable[0].session_id == "missing:1" + + asyncio.run(run()) + + +def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None: + async def run() -> None: + bus = MessageBus() + channel = MemoryChannelAdapter(bus, name="telegram") + inbound = await channel.publish_external_text( + "hello", + chat_id="chat-1", + message_id="message-1", + raw_payload={"platform": "telegram", "text": "hello"}, + ) + + queued = await bus.consume_inbound() + assert queued is inbound + assert queued.channel == "telegram" + assert queued.session_id == "telegram:chat-1" + assert queued.metadata["chat_id"] == "chat-1" + assert queued.metadata["message_id"] == "message-1" + assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"} + + asyncio.run(run()) + + def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None: class StartedChannel: name = "started" diff --git a/app-instance/backend/tests/unit/test_litellm_thinking_mode.py b/app-instance/backend/tests/unit/test_litellm_thinking_mode.py new file mode 100644 index 0000000..fad8956 --- /dev/null +++ b/app-instance/backend/tests/unit/test_litellm_thinking_mode.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio +import pytest +from types import SimpleNamespace + +from beaver.engine.providers.litellm import LiteLLMProvider + + +def test_qwen_thinking_mode_is_sent_as_chat_template_kwargs(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + + class Message: + content = "可以" + reasoning_content = "" + tool_calls = [] + + class Choice: + message = Message() + finish_reason = "stop" + + class Response: + choices = [Choice()] + usage = None + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion) + monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace()) + + provider = LiteLLMProvider( + api_key="sk-test", + api_base="https://oai.example.com/v1", + default_model="Qwen3.6-35B", + provider_name="openai", + ) + response = asyncio.run( + provider.chat( + [{"role": "user", "content": "只回复可以"}], + model="Qwen3.6-35B", + thinking_enabled=False, + ) + ) + + assert response.content == "可以" + assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}} + + +def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + + class Message: + content = "ok" + reasoning_content = None + tool_calls = [] + + class Choice: + message = Message() + finish_reason = "stop" + + class Response: + choices = [Choice()] + usage = None + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion) + monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace()) + + provider = LiteLLMProvider( + api_key="sk-test", + api_base="https://oai.example.com/v1", + default_model="gpt-4.1-mini", + provider_name="openai", + ) + asyncio.run( + provider.chat( + [{"role": "user", "content": "reply ok"}], + model="gpt-4.1-mini", + thinking_enabled=False, + ) + ) + + assert "extra_body" not in captured + + +def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + + class Message: + content = "ok" + reasoning_content = None + tool_calls = [] + + class Choice: + message = Message() + finish_reason = "stop" + + class Response: + choices = [Choice()] + usage = None + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion) + monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace()) + + provider = LiteLLMProvider( + api_key="sk-test", + api_base="https://oai.example.com/v1", + default_model="Qwen3.6-35B", + provider_name="openai", + ) + asyncio.run( + provider.chat( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "cron", + "arguments": {"action": "add", "mode": "notification"}, + }, + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "name": "cron", "content": "done"}, + ], + model="Qwen3.6-35B", + thinking_enabled=False, + ) + ) + + tool_call = captured["messages"][0]["tool_calls"][0] + assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}' diff --git a/app-instance/backend/tests/unit/test_main_agent_router.py b/app-instance/backend/tests/unit/test_main_agent_router.py new file mode 100644 index 0000000..8884bb5 --- /dev/null +++ b/app-instance/backend/tests/unit/test_main_agent_router.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import asyncio + +from beaver.engine.providers.base import LLMProvider, LLMResponse +from beaver.tasks import MainAgentRouter, TaskRecord + + +class RouterProvider(LLMProvider): + def __init__(self, response: str | Exception) -> None: + super().__init__() + self.response = response + self.calls: list[dict] = [] + + async def chat( + self, + messages: list[dict], + tools: list[dict] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + thinking_enabled: bool | None = None, + ) -> LLMResponse: + self.calls.append( + { + "max_tokens": max_tokens, + "temperature": temperature, + "model": model, + "thinking_enabled": thinking_enabled, + } + ) + if isinstance(self.response, Exception): + raise self.response + return LLMResponse(content=self.response, finish_reason="stop", provider_name="stub", model="stub-model") + + def get_default_model(self) -> str: + return "stub-model" + + +def _task() -> TaskRecord: + return TaskRecord( + task_id="task-1", + session_id="web:task", + description="实现任务连续性", + goal="实现任务连续性", + constraints=[], + priority=0, + status="awaiting_feedback", + creator="test", + created_at="now", + updated_at="now", + metadata={"short_title": "任务连续性"}, + ) + + +def test_router_continues_active_task_from_llm_decision() -> None: + provider = RouterProvider('{"action":"continue_task","reason":"related","short_title":"任务连续性"}') + decision = asyncio.run( + MainAgentRouter().classify( + "再把输入框标识也补上", + active_task=_task(), + provider=provider, + ) + ) + + assert decision.is_task + assert decision.starts_new_task is False + assert decision.short_title == "任务连续性" + assert provider.calls[0]["max_tokens"] == 256 + + +def test_router_receives_thinking_mode() -> None: + provider = RouterProvider('{"action":"simple_chat","reason":"simple"}') + decision = asyncio.run( + MainAgentRouter().classify( + "你好", + provider=provider, + thinking_enabled=False, + ) + ) + + assert not decision.is_task + assert provider.calls[0]["thinking_enabled"] is False + + +def test_router_closes_active_task_from_llm_decision() -> None: + decision = asyncio.run( + MainAgentRouter().classify( + "这个任务结束了", + active_task=_task(), + provider=RouterProvider('{"action":"close_task","reason":"user said done"}'), + ) + ) + + assert not decision.is_task + assert decision.closes_task is True + + +def test_router_fallback_keeps_active_task_but_not_new_task() -> None: + active = asyncio.run( + MainAgentRouter().classify( + "继续", + active_task=_task(), + provider=RouterProvider(RuntimeError("provider down")), + ) + ) + inactive = asyncio.run( + MainAgentRouter().classify( + "implement something", + active_task=None, + provider=RouterProvider(RuntimeError("provider down")), + ) + ) + + assert active.is_task + assert not inactive.is_task diff --git a/app-instance/backend/tests/unit/test_marketplace_and_hermes.py b/app-instance/backend/tests/unit/test_marketplace_and_hermes.py new file mode 100644 index 0000000..3534727 --- /dev/null +++ b/app-instance/backend/tests/unit/test_marketplace_and_hermes.py @@ -0,0 +1,142 @@ +import asyncio +import io +import json +import zipfile +from types import SimpleNamespace + +import pytest + +from beaver.interfaces.web.app import _create_skill_upload_draft +from beaver.services.hermes_migration import HermesMigrationService +from beaver.services.skillhub_service import SkillHubService +from beaver.skills.drafts import DraftService +from beaver.skills.specs import SkillSpecStore +from beaver.tools.mcp.wrapper import MCPToolWrapper + + +class FakeSkillHubService(SkillHubService): + async def _get_json(self, path, *, params=None): + if path == "/skills": + return { + "data": { + "items": [ + { + "slug": "multi-search-engine", + "displayName": "multi-search-engine", + "summary": "search", + "namespace": "global", + "downloadCount": 1, + "starCount": 0, + "publishedVersion": {"version": "20260413.065325"}, + } + ], + "total": 1, + "page": 0, + "size": 12, + } + } + if path == "/skills/global/multi-search-engine": + return { + "data": { + "slug": "multi-search-engine", + "displayName": "multi-search-engine", + "summary": "search", + "namespace": "global", + "downloadCount": 1, + "starCount": 0, + "publishedVersion": {"version": "20260413.065325"}, + } + } + if path == "/skills/global/multi-search-engine/versions/20260413.065325": + return {"data": {"version": "20260413.065325"}} + if path == "/skills/global/multi-search-engine/versions/20260413.065325/files": + return {"data": [{"filePath": "SKILL.md", "fileSize": 93}, {"filePath": "references/a.txt", "fileSize": 2}]} + raise AssertionError(path) + + async def _get_text(self, path, *, params): + if params["path"] == "SKILL.md": + return "---\nname: multi-search-engine\ndescription: Multi search\ntools:\n - web_search\n---\nUse search.\n" + return "ok" + + +def test_skillhub_search_detail_do_not_install_until_post_install(tmp_path): + store = SkillSpecStore(tmp_path) + service = FakeSkillHubService(store) + + search = asyncio.run(service.search(q="multi-search-engine")) + detail = asyncio.run(service.detail("global", "multi-search-engine")) + assert search["items"][0]["installed"] is False + assert detail["installed"] is False + assert store.get_skill_spec("multi-search-engine") is None + + install = asyncio.run(service.install("global", "multi-search-engine")) + assert install["ok"] is True + assert store.get_skill_spec("multi-search-engine") is not None + assert (tmp_path / "skills" / "multi-search-engine" / "versions" / install["version"] / "references" / "a.txt").read_text() == "ok" + + +def test_upload_skill_zip_rejects_path_traversal(tmp_path): + store = SkillSpecStore(tmp_path) + loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store)) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n") + archive.writestr("skill/../evil.txt", "x") + + with pytest.raises(ValueError, match="Unsafe archive entry"): + _create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue()) + + +def test_upload_skill_zip_keeps_supporting_files_on_draft(tmp_path): + store = SkillSpecStore(tmp_path) + loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store)) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n") + archive.writestr("skill/references/a.txt", "context") + + draft = _create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue()) + upload_dir = draft["evidence_refs"][0]["supporting_upload_dir"] + assert (tmp_path / "skills" / "skill" / "draft_uploads" / draft["draft_id"] / "references" / "a.txt").read_text() == "context" + assert upload_dir.endswith(draft["draft_id"]) + + +def test_hermes_migration_manifest_includes_no_credential_skill_and_skips_api_skill(tmp_path): + repo = tmp_path / "hermes" + safe = repo / "skills" / "safe" + unsafe = repo / "skills" / "unsafe" + safe.mkdir(parents=True) + unsafe.mkdir(parents=True) + safe.joinpath("SKILL.md").write_text("---\nname: safe\n---\nUse local files only.\n", encoding="utf-8") + unsafe.joinpath("SKILL.md").write_text("---\nname: unsafe\n---\nRequires API_KEY.\n", encoding="utf-8") + + store = SkillSpecStore(tmp_path / "workspace") + manifest = HermesMigrationService(store).migrate(repo) + included = {item["skill_name"] for item in manifest["included"]} + skipped = {item.get("skill_name"): item["reason"] for item in manifest["skipped"]} + + assert "safe" in included + assert skipped["unsafe"] == "requires_external_credentials" + assert store.get_skill_spec("safe") is not None + manifest_path = tmp_path / "workspace" / "hermes_migration_manifest.json" + assert json.loads(manifest_path.read_text(encoding="utf-8"))["source"] == "hermes-agent" + + +def test_mcp_wrapper_metadata_preserves_server_id_with_underscores(): + tool_def = SimpleNamespace(name="auth_status", description="Auth", inputSchema={"type": "object", "properties": {}}) + + async def call_tool(_name, _arguments): + return SimpleNamespace(content=[], structuredContent={"ok": True}) + + wrapper = MCPToolWrapper( + "outlook_mcp", + tool_def, + call_tool, + kind="online", + category="outlook", + display_name="Outlook", + ) + + assert wrapper.spec.name == "mcp_outlook_mcp_auth_status" + assert wrapper.spec.metadata["server_id"] == "outlook_mcp" + assert wrapper.spec.metadata["original_tool_name"] == "auth_status" diff --git a/app-instance/backend/tests/unit/test_phase5_skills_runtime.py b/app-instance/backend/tests/unit/test_phase5_skills_runtime.py index 7c9e64f..98f8447 100644 --- a/app-instance/backend/tests/unit/test_phase5_skills_runtime.py +++ b/app-instance/backend/tests/unit/test_phase5_skills_runtime.py @@ -298,8 +298,29 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path: ended_at=recent, success=True, finish_reason="stop", + feedback={"feedback_type": "satisfied"}, + activated_skills=[], + task_id=f"task-new-{index}", + attempt_index=1, + validation_result={"accepted": True, "score": 0.9}, + ) + ) + + for index in range(2): + run_store.append_run_record( + RunRecord( + run_id=f"simple-chat-{index}", + session_id="session-simple", + task_text="你是谁", + started_at=recent, + ended_at=recent, + success=True, + finish_reason="stop", feedback={}, activated_skills=[], + task_id=None, + attempt_index=None, + validation_result=None, ) ) @@ -329,8 +350,11 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path: ended_at=recent, success=True, finish_reason="stop", - feedback={}, + feedback={"feedback_type": "satisfied"}, activated_skills=receipts, + task_id=f"task-merge-{index}", + attempt_index=1, + validation_result={"accepted": True, "score": 0.9}, ) ) for receipt in receipts: @@ -382,6 +406,9 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path: kinds = {candidate.kind for candidate in candidates} assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds + new_candidates = [candidate for candidate in candidates if candidate.kind == "new_skill"] + assert new_candidates + assert all("simple-chat" not in run_id for candidate in new_candidates for run_id in candidate.source_run_ids) retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill") retire_draft = asyncio.run( @@ -396,6 +423,100 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path: assert store.read_draft("svn-migration", retire_draft.draft_id) is not None +def test_skill_learning_service_generates_task_scoped_candidates(tmp_path: Path) -> None: + store = SkillSpecStore(tmp_path) + run_store = RunMemoryStore(tmp_path / "memory" / "runs") + learning_store = SkillLearningStore(tmp_path / "memory" / "skills") + service = SkillLearningService( + run_store=run_store, + learning_store=learning_store, + draft_service=DraftService(store), + evidence_selector=EvidenceSelector(run_store), + ) + now = datetime.now(timezone.utc).isoformat() + receipt = _receipt( + run_id="task-run-1", + session_id="session-task", + skill_name="api-review", + skill_version="v0001", + activated_at=now, + ) + run_store.append_run_record( + RunRecord( + run_id="task-run-1", + session_id="session-task", + task_id="task-1", + attempt_index=1, + task_text="Review API compatibility", + started_at=now, + ended_at=now, + success=True, + finish_reason="stop", + feedback={"feedback_type": "satisfied"}, + activated_skills=[receipt], + validation_result={"accepted": True, "score": 0.9}, + ) + ) + run_store.append_run_record( + RunRecord( + run_id="other-task-run", + session_id="session-other", + task_id="task-2", + attempt_index=1, + task_text="Review API compatibility", + started_at=now, + ended_at=now, + success=True, + finish_reason="stop", + feedback={"feedback_type": "satisfied"}, + activated_skills=[], + validation_result={"accepted": True, "score": 0.9}, + ) + ) + + candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1") + + assert [candidate.candidate_id for candidate in candidates] == ["revise:api-review:v0001:task:task-1"] + assert candidates[0].source_run_ids == ["task-run-1"] + assert candidates[0].related_skill_names == ["api-review"] + assert candidates[0].evidence["task_id"] == "task-1" + + +def test_skill_learning_service_generates_new_skill_for_task_without_published_skills(tmp_path: Path) -> None: + store = SkillSpecStore(tmp_path) + run_store = RunMemoryStore(tmp_path / "memory" / "runs") + learning_store = SkillLearningStore(tmp_path / "memory" / "skills") + service = SkillLearningService( + run_store=run_store, + learning_store=learning_store, + draft_service=DraftService(store), + evidence_selector=EvidenceSelector(run_store), + ) + now = datetime.now(timezone.utc).isoformat() + run_store.append_run_record( + RunRecord( + run_id="task-run-1", + session_id="session-task", + task_id="task-1", + attempt_index=1, + task_text="Generate migration checklist", + started_at=now, + ended_at=now, + success=True, + finish_reason="stop", + feedback={"feedback_type": "satisfied"}, + activated_skills=[], + validation_result={"accepted": True, "score": 0.9}, + ) + ) + + candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1") + + assert [candidate.candidate_id for candidate in candidates] == ["new:task:task-1"] + assert candidates[0].kind == "new_skill" + assert candidates[0].source_run_ids == ["task-run-1"] + + def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None: skill = SkillContext( name="docker-debug", @@ -446,7 +567,7 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None: skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted") assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007" assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug" - assert skill_effects.event_payload["learning_candidate_enabled"] is False + assert skill_effects.event_payload["candidate_generation_allowed"] is False assert skill_effects.event_payload["learning_candidates"] == [] run_records = loaded.run_memory_store.list_runs() diff --git a/app-instance/backend/tests/unit/test_process_projection.py b/app-instance/backend/tests/unit/test_process_projection.py index 596dc1b..c7e7faf 100644 --- a/app-instance/backend/tests/unit/test_process_projection.py +++ b/app-instance/backend/tests/unit/test_process_projection.py @@ -53,7 +53,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None: "node_id": "research", "skill_query": "research workflow", "selected_skill_names": ["research-workflow"], - "generated_skill_draft_id": None, + "ephemeral_guidance_id": None, + "ephemeral_guidance_name": None, "ephemeral_used": False, "reason": "matched published skill", } @@ -80,7 +81,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None: "skill_query": "research workflow", "selected_skill_names": ["research-workflow"], "ephemeral_skill_names": [], - "generated_skill_draft_id": None, + "ephemeral_guidance_id": None, + "ephemeral_guidance_name": None, "ephemeral_used": False, "finish_reason": "stop", } @@ -118,5 +120,83 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None: sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run") assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"] assert sub_run["metadata"]["skill_query"] == "research workflow" + assert sub_run["metadata"]["ephemeral_guidance_id"] is None assert any(event["actor_name"] == "Validator" for event in projection["events"]) assert any(run["session_id"] == "web:test" for run in projection["runs"]) + + +def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None: + session = SessionManager(tmp_path) + run_store = RunMemoryStore(tmp_path / "memory" / "runs") + run_store.append_run_record( + RunRecord( + run_id="sub-run", + session_id="sub-session", + task_id="task-1", + attempt_index=1, + task_text="sub task", + started_at="2026-01-01T00:00:01+00:00", + ended_at="2026-01-01T00:00:02+00:00", + success=True, + finish_reason="stop", + ) + ) + session.append_message( + "web:test", + role="system", + event_type="task_execution_planned", + event_payload={ + "task_id": "task-1", + "attempt_index": 1, + "plan_mode": "team", + "strategy": "sequence", + "node_ids": ["research"], + "ephemeral_guidance_ids": ["eg_123"], + "skill_resolution_report": [ + { + "node_id": "research", + "skill_query": "research workflow", + "selected_skill_names": [], + "ephemeral_guidance_id": "eg_123", + "ephemeral_guidance_name": "research-workflow", + "ephemeral_used": True, + "reason": "generated ephemeral guidance", + } + ], + }, + context_visible=False, + ) + session.append_message( + "web:test", + role="system", + event_type="task_team_run_completed", + event_payload={ + "task_id": "task-1", + "attempt_index": 1, + "team_success": True, + "team_run_ids": ["sub-run"], + "node_results": [ + { + "node_id": "research", + "success": True, + "output_text": "evidence", + "run_id": "sub-run", + "skill_query": "research workflow", + "selected_skill_names": [], + "ephemeral_skill_names": ["ephemeral:research-workflow"], + "ephemeral_guidance_id": "eg_123", + "ephemeral_guidance_name": "research-workflow", + "ephemeral_used": True, + "finish_reason": "stop", + } + ], + }, + context_visible=False, + ) + + projection = SessionProcessProjector(session, run_store).project("web:test") + + sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run") + assert sub_run["metadata"]["ephemeral_guidance_id"] == "eg_123" + assert projection["artifacts"][0]["artifact_id"] == "sub-run:ephemeral-guidance:eg_123" + assert projection["artifacts"][0]["metadata"]["ephemeral_guidance_name"] == "research-workflow" diff --git a/app-instance/backend/tests/unit/test_session_archive.py b/app-instance/backend/tests/unit/test_session_archive.py new file mode 100644 index 0000000..5b5eee6 --- /dev/null +++ b/app-instance/backend/tests/unit/test_session_archive.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.engine.session import SessionManager +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_archived_sessions_can_be_hidden_from_default_web_list(tmp_path: Path) -> None: + manager = SessionManager(tmp_path) + manager.ensure_session("web:keep", source="web") + manager.ensure_session("web:archived", source="web") + manager.end_session("web:archived", "archived") + + visible = manager.list_sessions_rich(exclude_end_reasons=["archived"]) + visible_ids = {row["id"] for row in visible} + + assert "web:keep" in visible_ids + assert "web:archived" not in visible_ids + assert manager.get_session("web:archived")["end_reason"] == "archived" + + +def test_archived_sessions_remain_available_to_history_search(tmp_path: Path) -> None: + manager = SessionManager(tmp_path) + manager.ensure_session("web:archived", source="web") + manager.end_session("web:archived", "archived") + + all_sessions = manager.list_sessions_rich() + + assert {row["id"] for row in all_sessions} == {"web:archived"} + + +def test_visible_history_excludes_error_and_incomplete_runs(tmp_path: Path) -> None: + manager = SessionManager(tmp_path) + manager.ensure_session("web:history", source="web") + manager.append_message("web:history", run_id="ok-run", role="user", content="hello") + manager.append_message("web:history", run_id="ok-run", role="assistant", content="hi", finish_reason="stop") + manager.append_message( + "web:history", + run_id="ok-run", + role="assistant", + content=None, + tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "echo", "arguments": "{}"}}], + ) + manager.append_message( + "web:history", + run_id="ok-run", + role="tool", + content="tool result", + tool_call_id="call-1", + ) + manager.append_message( + "web:history", + run_id="ok-run", + role="system", + event_type="run_completed", + content="hi", + context_visible=False, + ) + manager.append_message("web:history", run_id="error-run", role="user", content="bad") + manager.append_message( + "web:history", + run_id="error-run", + role="assistant", + content="Error: provider failed", + finish_reason="error", + ) + manager.append_message( + "web:history", + run_id="error-run", + role="system", + event_type="run_completed", + content="Error: provider failed", + finish_reason="error", + context_visible=False, + ) + manager.append_message("web:history", run_id="pending-run", role="user", content="pending") + + history = manager.get_visible_history("web:history") + + assert [(message["role"], message["content"]) for message in history] == [ + ("user", "hello"), + ("assistant", "hi"), + ] + + +def test_web_archive_route_does_not_create_archive_suffix_session(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + create_response = client.post("/api/sessions/web:alpha") + archive_response = client.post("/api/sessions/web:alpha/archive") + sessions_response = client.get("/api/sessions") + + assert create_response.status_code == 200 + assert archive_response.status_code == 200 + assert archive_response.json() == {"ok": True, "archived": True} + assert sessions_response.status_code == 200 + + loaded = service.create_loop().boot() + assert loaded.session_manager.get_session("web:alpha")["end_reason"] == "archived" # type: ignore[union-attr] + assert loaded.session_manager.get_session("web:alpha/archive") is None # type: ignore[union-attr] + assert sessions_response.json() == [] diff --git a/app-instance/backend/tests/unit/test_skill_assembler.py b/app-instance/backend/tests/unit/test_skill_assembler.py new file mode 100644 index 0000000..8a92def --- /dev/null +++ b/app-instance/backend/tests/unit/test_skill_assembler.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from beaver.engine.providers.base import LLMProvider, LLMResponse +from beaver.skills.assembler.task_assembler import SkillAssembler + + +class RecordingProvider(LLMProvider): + def __init__(self) -> None: + super().__init__() + self.thinking_enabled: bool | None = None + + async def chat( + self, + messages: list[dict], + tools: list[dict] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + thinking_enabled: bool | None = None, + ) -> LLMResponse: + self.thinking_enabled = thinking_enabled + return LLMResponse(content='["daily-news"]', provider_name="stub", model="stub-model") + + def get_default_model(self) -> str: + return "stub-model" + + +class SequencedProvider(LLMProvider): + def __init__(self, responses: list[str]) -> None: + super().__init__() + self.responses = list(responses) + self.messages: list[list[dict]] = [] + + async def chat( + self, + messages: list[dict], + tools: list[dict] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + thinking_enabled: bool | None = None, + ) -> LLMResponse: + self.messages.append(messages) + content = self.responses.pop(0) + return LLMResponse(content=content, provider_name="stub", model="stub-model") + + def get_default_model(self) -> str: + return "stub-model" + + +class StaticRetriever: + async def retrieve(self, **kwargs): + return kwargs["candidates"][: kwargs["top_k"]] + + +class LoaderWithFullSkill: + def build_selection_candidates(self) -> list[dict[str, str]]: + return [ + { + "name": "docker-debug", + "description": "General container tips.", + "version": "v1", + "content_hash": "abc", + } + ] + + def load_published_skill(self, name: str) -> str | None: + if name != "docker-debug": + return None + return """--- +description: General container tips. +tools: + - search_files +--- + +# Docker Debug + +Use this skill when doing Docker log triage and container failure analysis. +""" + + def get_skill_record(self, name: str): + return SimpleNamespace(version="v1", content_hash="abc", tool_hints=["search_files"]) + + +def test_skill_selection_receives_thinking_mode() -> None: + provider = RecordingProvider() + assembler = SkillAssembler(loader=SimpleNamespace()) + + selected = asyncio.run( + assembler._select_skill_names( + task_description="summarize daily news", + candidates=[{"name": "daily-news", "description": "Summarize news"}], + provider=provider, + model="Qwen3.6-35B", + thinking_enabled=False, + ) + ) + + assert selected == ["daily-news"] + assert provider.thinking_enabled is False + + +def test_skill_assembler_loads_detail_directly_for_small_candidate_sets() -> None: + provider = SequencedProvider(['["docker-debug"]']) + assembler = SkillAssembler(loader=LoaderWithFullSkill(), retriever=StaticRetriever()) + + result = asyncio.run( + assembler.assemble( + task_description="debug a failing Docker container", + provider=provider, + model="stub-model", + ) + ) + + assert [skill.name for skill in result.activated_skills] == ["docker-debug"] + assert result.activated_skills[0].tool_hints == ["search_files"] + assert [item["stage"] for item in result.llm_interactions] == ["final"] + assert len(provider.messages) == 1 + first_user_prompt = provider.messages[0][1]["content"] + assert "Use this skill when doing Docker log triage" in first_user_prompt + + +def test_skill_assembler_shortlists_before_loading_detail_for_large_candidate_sets() -> None: + provider = SequencedProvider(['["docker-debug"]', '["docker-debug"]']) + loader = LoaderWithFullSkill() + original_candidates = loader.build_selection_candidates + loader.build_selection_candidates = lambda: [ + *original_candidates(), + { + "name": "other-skill", + "description": "Other workflow.", + "version": "v1", + "content_hash": "def", + }, + ] + assembler = SkillAssembler( + loader=loader, + retriever=StaticRetriever(), + max_detailed_candidates=1, + ) + + result = asyncio.run( + assembler.assemble( + task_description="debug a failing Docker container", + provider=provider, + model="stub-model", + ) + ) + + assert [skill.name for skill in result.activated_skills] == ["docker-debug"] + assert [item["stage"] for item in result.llm_interactions] == ["shortlist", "final"] + assert len(provider.messages) == 2 + assert "Use this skill when doing Docker log triage" not in provider.messages[0][1]["content"] + assert "Use this skill when doing Docker log triage" in provider.messages[1][1]["content"] diff --git a/app-instance/backend/tests/unit/test_skill_learning_eval.py b/app-instance/backend/tests/unit/test_skill_learning_eval.py index a202a1b..61c7d56 100644 --- a/app-instance/backend/tests/unit/test_skill_learning_eval.py +++ b/app-instance/backend/tests/unit/test_skill_learning_eval.py @@ -90,6 +90,7 @@ def test_eval_pass_allows_publish_after_safety_and_review(tmp_path: Path) -> Non report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle())) safety = pipeline.check_safety(draft.skill_name, draft.draft_id) + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester") @@ -111,6 +112,7 @@ def test_eval_regression_blocks_publish(tmp_path: Path) -> None: report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle())) pipeline.check_safety(draft.skill_name, draft.draft_id) + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") assert report.passed is False diff --git a/app-instance/backend/tests/unit/test_skill_learning_pipeline.py b/app-instance/backend/tests/unit/test_skill_learning_pipeline.py index d802f82..3513493 100644 --- a/app-instance/backend/tests/unit/test_skill_learning_pipeline.py +++ b/app-instance/backend/tests/unit/test_skill_learning_pipeline.py @@ -68,6 +68,39 @@ def test_pipeline_lists_candidates_and_moves_draft_through_review(tmp_path: Path assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value +def test_pipeline_approve_requires_submitted_review(tmp_path: Path) -> None: + pipeline = _pipeline(tmp_path) + draft = pipeline.draft_service.create_new_skill_draft( + skill_name="needs-review", + proposed_content="# Needs Review\n\nDo the thing.", + proposed_frontmatter={"description": "needs review"}, + created_by="test", + reason="test", + ) + + with pytest.raises(ValueError, match="in review before approval"): + pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") + + +def test_pipeline_does_not_resubmit_terminal_draft(tmp_path: Path) -> None: + pipeline = _pipeline(tmp_path) + draft = pipeline.draft_service.create_new_skill_draft( + skill_name="already-published", + proposed_content="# Already Published\n\nDo the thing.", + proposed_frontmatter={"description": "already published"}, + created_by="test", + reason="test", + ) + + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") + pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") + pipeline.check_safety(draft.skill_name, draft.draft_id) + pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester") + + with pytest.raises(ValueError, match="draft status before review submission"): + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") + + def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None: pipeline = _pipeline(tmp_path) draft = pipeline.draft_service.create_new_skill_draft( @@ -80,5 +113,22 @@ def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None: pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester") - with pytest.raises(ValueError, match="approved"): + with pytest.raises(ValueError, match="Draft not found"): pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester") + assert pipeline.draft_service.get_draft(draft.skill_name, draft.draft_id) is None + + +def test_pipeline_reject_removes_draft_from_review_list(tmp_path: Path) -> None: + pipeline = _pipeline(tmp_path) + draft = pipeline.draft_service.create_new_skill_draft( + skill_name="remove-skill", + proposed_content="# Remove\n\nNo longer needed.", + proposed_frontmatter={"description": "remove"}, + created_by="test", + reason="test", + ) + + review = pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester") + + assert review.status == SkillReviewState.REJECTED.value + assert pipeline.list_drafts() == [] diff --git a/app-instance/backend/tests/unit/test_skill_learning_safety.py b/app-instance/backend/tests/unit/test_skill_learning_safety.py index 05ed3a8..4c7666a 100644 --- a/app-instance/backend/tests/unit/test_skill_learning_safety.py +++ b/app-instance/backend/tests/unit/test_skill_learning_safety.py @@ -65,6 +65,7 @@ def test_safety_marks_dangerous_tools_high_and_requires_confirm(tmp_path: Path) ) report = pipeline.check_safety(draft.skill_name, draft.draft_id) + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") assert report.passed is True @@ -84,6 +85,7 @@ def test_publish_requires_safety_report(tmp_path: Path) -> None: created_by="test", reason="test", ) + pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") with pytest.raises(ValueError, match="safety report"): diff --git a/app-instance/backend/tests/unit/test_task_mode_feedback.py b/app-instance/backend/tests/unit/test_task_mode_feedback.py index 788003c..035dc87 100644 --- a/app-instance/backend/tests/unit/test_task_mode_feedback.py +++ b/app-instance/backend/tests/unit/test_task_mode_feedback.py @@ -12,6 +12,7 @@ from beaver.engine.context.builder import ContextBuilder, ContextBuildInput from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.factory import ProviderBundle from beaver.services.agent_service import AgentService +from beaver.skills.assembler import SkillAssemblyResult from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService @@ -67,7 +68,25 @@ class FakeLearningCandidate: return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"} -def _bundle(*responses: str) -> ProviderBundle: +class RecordingSkillAssembler: + def __init__(self) -> None: + self.task_descriptions: list[str] = [] + + async def assemble(self, **kwargs) -> SkillAssemblyResult: + self.task_descriptions.append(kwargs["task_description"]) + return SkillAssemblyResult() + + +def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse: + return LLMResponse( + content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}', + finish_reason="stop", + provider_name="stub", + model="stub-model", + ) + + +def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle: return ProviderBundle( main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), main_provider=StubProvider( @@ -81,6 +100,8 @@ def _bundle(*responses: str) -> ProviderBundle: for response in responses ] ), + auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), + auxiliary_provider=StubProvider([_route_response(route_action)]), ) @@ -110,6 +131,25 @@ def _provider_bundle(provider: StubProvider) -> ProviderBundle: return ProviderBundle( main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), main_provider=provider, + auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), + auxiliary_provider=StubProvider([_route_response("new_task")]), + ) + + +def _main_only_bundle(*responses: str) -> ProviderBundle: + return ProviderBundle( + main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), + main_provider=StubProvider( + [ + LLMResponse( + content=response, + finish_reason="stop", + provider_name="stub", + model="stub-model", + ) + for response in responses + ] + ), ) @@ -126,7 +166,7 @@ def test_simple_question_does_not_create_task(tmp_path: Path) -> None: service.process_direct( "hello?", session_id="web:simple", - provider_bundle=_bundle("hi"), + provider_bundle=_bundle("hi", route_action="simple_chat"), ) ) loaded = service.create_loop().boot() @@ -165,8 +205,89 @@ def test_complex_request_creates_task_and_records_validation(tmp_path: Path) -> assert any(event.event_type == "task_validation_snapshotted" for event in events) assert run_record.task_id == result.task_id assert run_record.validation_result["accepted"] is True - assert skill_effects.event_payload["learning_candidate_enabled"] is False + assert skill_effects.event_payload["candidate_generation_allowed"] is False assert skill_effects.event_payload["learning_candidates"] == [] + assert task.metadata["short_title"] == "Test task" + + +def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None: + skill_assembler = RecordingSkillAssembler() + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=_single_planner(), + validation_service=StubValidationService( + [ValidationResult(passed=True, score=1.0, validator="test")] + ), + skill_assembler=skill_assembler, + ) + ) + + result = asyncio.run( + service.process_direct( + "继续按刚才的方案改", + session_id="web:task-skill-query", + provider_bundle=_bundle("done", route_action="new_task"), + ) + ) + + assert result.task_id + assert skill_assembler.task_descriptions + query = skill_assembler.task_descriptions[0] + assert "Task goal:" in query + assert "Current user request:" in query + assert "Previously activated skills:" in query + assert "If no published skill matches, return []" in query + + +def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=_single_planner(), + validation_service=StubValidationService( + [ + ValidationResult(passed=True, score=0.9, validator="test"), + ValidationResult(passed=True, score=0.9, validator="test"), + ] + ), + ) + ) + + first = asyncio.run( + service.process_direct( + "implement the search workflow", + session_id="web:continue", + provider_bundle=_bundle("first done", route_action="new_task"), + ) + ) + second = asyncio.run( + service.process_direct( + "also add tests for it", + session_id="web:continue", + provider_bundle=_bundle("tests added", route_action="continue_task"), + ) + ) + loaded = service.create_loop().boot() + task = loaded.task_service.get_task(first.task_id) + + assert task is not None + assert second.task_id == first.task_id + assert len(task.run_ids) == 2 + + closed = asyncio.run( + service.process_direct( + "这个任务结束了", + session_id="web:continue", + provider_bundle=_bundle("好的,已结束。", route_action="close_task"), + ) + ) + task = loaded.task_service.get_task(first.task_id) + + assert closed.task_id is None + assert task is not None + assert task.status == "closed" + assert loaded.task_service.active_task_view("web:continue") is None def test_validation_failure_retries_once(tmp_path: Path) -> None: @@ -229,11 +350,11 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None: loaded = service.create_loop().boot() learning_calls = [] - def build_learning_candidates() -> list[FakeLearningCandidate]: - learning_calls.append("called") + def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]: + learning_calls.append((task_id, trigger_run_id)) return [FakeLearningCandidate()] - loaded.skill_learning_service.build_learning_candidates = build_learning_candidates + loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task feedback = asyncio.run( service.submit_feedback( @@ -247,7 +368,7 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None: assert feedback["learning_candidates"] == [ {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"} ] - assert learning_calls == ["called"] + assert learning_calls == [(result.task_id, result.run_id)] service2 = AgentService( loader=EngineLoader( @@ -279,6 +400,14 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None: assert abandon_feedback["task_status"] == "abandoned" assert abandon_feedback["learning_candidates"] == [] + loaded2 = service2.create_loop().boot() + failure_events = [ + event + for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id) + if event.event_type == "task_failure_evidence_recorded" + ] + assert len(failure_events) == 1 + assert loaded2.memory_service.get_store().memory_entries == [] def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None: @@ -466,7 +595,7 @@ def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None: events = loaded.session_manager.get_run_event_records(record.session_id, run_id) skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"] assert skill_effects - assert skill_effects[-1].event_payload["learning_candidate_enabled"] is False + assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None: @@ -490,17 +619,43 @@ def test_context_builder_strips_ui_projection_fields_from_provider_history() -> assert assistant == {"role": "assistant", "content": "done"} +def test_context_builder_normalizes_persisted_tool_arguments() -> None: + result = ContextBuilder().build_messages( + ContextBuildInput( + history=[ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "cron", + "arguments": {"action": "add", "mode": "notification"}, + }, + } + ], + } + ], + ) + ) + + tool_call = result.messages[-1]["tool_calls"][0] + assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}' + + def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None: task_service = TaskService(tmp_path / "tasks") task = task_service.create_task(session_id="web:validator", description="implement validator handling") validation = asyncio.run( ValidationService().validate_task_result( - task=task, - user_message="implement validator handling", - final_output="done", - provider_bundle=_bundle("not json"), + task=task, + user_message="implement validator handling", + final_output="done", + provider_bundle=_main_only_bundle("not json"), + ) ) - ) assert validation.accepted is False assert validation.validator == "llm_error" diff --git a/app-instance/backend/tests/unit/test_task_skill_resolver.py b/app-instance/backend/tests/unit/test_task_skill_resolver.py index 79021b3..198d7fd 100644 --- a/app-instance/backend/tests/unit/test_task_skill_resolver.py +++ b/app-instance/backend/tests/unit/test_task_skill_resolver.py @@ -9,7 +9,7 @@ from beaver.engine.context import SkillContext from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.factory import ProviderBundle from beaver.skills.drafts import DraftService -from beaver.skills.learning import MissingSkillSynthesizer +from beaver.skills.learning import EphemeralGuidanceSynthesizer from beaver.skills.publisher import SkillPublisher from beaver.skills.reviews import ReviewService from beaver.skills.specs import SkillSpecStore @@ -116,12 +116,12 @@ def test_task_skill_resolver_pins_matching_published_skill(tmp_path: Path) -> No assert reports[0].ephemeral_used is False -def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(tmp_path: Path) -> None: +def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path: Path) -> None: provider = RecordingProvider( [ """ { - "skill_name": "api-compatibility-review", + "guidance_name": "api-compatibility-review", "description": "Review API compatibility", "content": "# API Compatibility Review\\n\\nCheck schema compatibility.", "tags": ["api", "review"] @@ -133,7 +133,7 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t resolver = TaskSkillResolver( skills_loader=SkillsLoader(tmp_path), draft_service=DraftService(store), - missing_skill_synthesizer=MissingSkillSynthesizer(), + missing_skill_synthesizer=EphemeralGuidanceSynthesizer(), ) graph = ExecutionGraph( strategy="sequence", @@ -163,13 +163,14 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t ) drafts = store.list_drafts("api-compatibility-review") - assert len(drafts) == 1 + assert drafts == [] assert store.list_published_skill_names() == [] assert resolved.nodes[0].inherited_pinned_skills == [] assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1 context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0] - assert context.name == "draft:api-compatibility-review" - assert context.version == f"draft:{drafts[0].draft_id}" - assert context.activation_reason == "generated_missing_skill" - assert reports[0].generated_skill_draft_id == drafts[0].draft_id + assert context.name == "ephemeral:api-compatibility-review" + assert context.version.startswith("ephemeral:eg_") + assert context.activation_reason == "ephemeral_guidance" + assert reports[0].ephemeral_guidance_id is not None + assert reports[0].ephemeral_guidance_name == "api-compatibility-review" assert reports[0].ephemeral_used is True diff --git a/app-instance/backend/tests/unit/test_tool_assembler.py b/app-instance/backend/tests/unit/test_tool_assembler.py index 3eabe48..5912f69 100644 --- a/app-instance/backend/tests/unit/test_tool_assembler.py +++ b/app-instance/backend/tests/unit/test_tool_assembler.py @@ -83,7 +83,6 @@ tools: registry = ToolRegistry() registry.register(DummyTool("memory", toolset="memory", always_available=True)) - registry.register(DummyTool("skill_view", toolset="skills", always_available=True)) registry.register(DummyTool("terminal", toolset="shell")) registry.register(DummyTool("search_files", toolset="file")) registry.register(DummyTool("echo", toolset="debug")) @@ -100,7 +99,7 @@ tools: ) ) - assert [spec.name for spec in selected] == ["memory", "skill_view", "terminal", "search_files"] + assert [spec.name for spec in selected] == ["memory", "terminal", "search_files"] def test_embedding_fallback_can_return_all_or_top_k() -> None: diff --git a/app-instance/backend/tests/unit/test_websocket_chat.py b/app-instance/backend/tests/unit/test_websocket_chat.py new file mode 100644 index 0000000..36e3cbc --- /dev/null +++ b/app-instance/backend/tests/unit/test_websocket_chat.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +@dataclass(slots=True) +class StubRunResult: + session_id: str + run_id: str = "run-1" + output_text: str = "ok" + finish_reason: str = "stop" + tool_iterations: int = 0 + provider_name: str | None = "stub" + model: str | None = "stub-model" + usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3}) + task_id: str | None = "task-1" + task_status: str | None = "awaiting_feedback" + validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True}) + + +class StubAgentService(AgentService): + def __init__(self, *, fail: bool = False) -> None: + super().__init__() + self.fail = fail + self.calls: list[dict[str, Any]] = [] + + async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override] + self.calls.append({"message": message, **kwargs}) + if self.fail: + raise RuntimeError("boom") + return StubRunResult( + session_id=kwargs.get("session_id") or "web:default", + output_text=f"echo:{message}", + ) + + +def test_websocket_ping_pong() -> None: + app = create_app(service=StubAgentService(), manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/ws/web:alpha") as websocket: + websocket.send_json({"type": "ping"}) + assert websocket.receive_json() == {"type": "pong"} + + +def test_websocket_message_returns_chat_metadata_and_session_updated() -> None: + service = StubAgentService() + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/ws/web:alpha") as websocket: + websocket.send_json( + { + "type": "message", + "content": "hello", + "metadata": {"source": "test"}, + "attachments": [{"file_id": "file-1", "name": "a.txt"}], + } + ) + assert websocket.receive_json() == {"type": "status", "status": "thinking"} + message = websocket.receive_json() + session_updated = websocket.receive_json() + + assert service.calls == [ + { + "message": "hello", + "session_id": "web:alpha", + "source": "websocket", + "user_id": None, + "title": None, + "execution_context": None, + "model": None, + "provider_name": None, + "embedding_model": None, + } + ] + assert message["type"] == "message" + assert message["role"] == "assistant" + assert message["content"] == "echo:hello" + assert message["session_id"] == "web:alpha" + assert message["run_id"] == "run-1" + assert message["task_id"] == "task-1" + assert message["task_status"] == "awaiting_feedback" + assert message["validation_result"] == {"accepted": True} + assert message["validation_status"] == "passed" + assert message["metadata"]["input_metadata"] == { + "source": "test", + "attachments": [{"file_id": "file-1", "name": "a.txt"}], + } + assert session_updated == { + "type": "session_updated", + "session_id": "web:alpha", + "source": "websocket", + } + + +def test_websocket_empty_content_returns_error_without_runtime_call() -> None: + service = StubAgentService() + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/ws/web:alpha") as websocket: + websocket.send_json({"type": "message", "content": " "}) + assert websocket.receive_json() == {"type": "error", "error": "'content' is required"} + + assert service.calls == [] + + +def test_websocket_runtime_error_returns_assistant_error_message() -> None: + service = StubAgentService(fail=True) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + with client.websocket_connect("/ws/web:alpha") as websocket: + websocket.send_json({"type": "message", "content": "hello"}) + assert websocket.receive_json() == {"type": "status", "status": "thinking"} + message = websocket.receive_json() + websocket.send_json({"type": "ping"}) + pong = websocket.receive_json() + + assert message["type"] == "message" + assert message["role"] == "assistant" + assert message["session_id"] == "web:alpha" + assert message["finish_reason"] == "error" + assert "boom" in message["content"] + assert pong == {"type": "pong"} diff --git a/app-instance/backend/uv.lock b/app-instance/backend/uv.lock index 0ebc47d..43b08d5 100644 --- a/app-instance/backend/uv.lock +++ b/app-instance/backend/uv.lock @@ -238,6 +238,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "croniter" }, { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, @@ -245,6 +246,7 @@ dependencies = [ { name = "litellm" }, { name = "openai" }, { name = "pydantic" }, + { name = "python-multipart" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -257,6 +259,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.51.0,<1.0.0" }, + { name = "croniter", specifier = ">=6.0.0,<7.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastmcp", specifier = ">=3.0.0,<4.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" }, @@ -265,6 +268,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.79.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, + { name = "python-multipart", specifier = ">=0.0.20,<1.0.0" }, { name = "typer", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, ] @@ -493,6 +497,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -1927,6 +1943,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -2317,6 +2345,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" diff --git a/app-instance/backend/施工指南.md b/app-instance/backend/施工指南.md index 8a0327a..1345942 100644 --- a/app-instance/backend/施工指南.md +++ b/app-instance/backend/施工指南.md @@ -35,7 +35,7 @@ - `task_id` - `task_mode` - `attempt_index` - - `learning_candidate_enabled` + - `allow_candidate_generation` 4. `RunRecord` 已记录: - `task_id` - `attempt_index` @@ -55,7 +55,7 @@ 8. 学习触发已经收紧。 - Task 模式 run 不再直接生成成功学习候选 - 只有“自动验证通过 + 用户点击满意”才触发成功学习候选 - - “放弃”写 Failure Memory,不生成成功 Skill draft + - “放弃”只写失败证据,不默认写主 memory,不生成成功 Skill draft 9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。 - 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult` - 新增 `TeamService.run_team(...)` 作为内部服务入口 @@ -72,7 +72,7 @@ - `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team` - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设 - 新增 `beaver/tasks/skill_resolver.py` - - `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用 + - `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用 - 只允许 v1 已实现的 `sequence / parallel / dag` - planner 失败或 graph 非法时降级为 `single` - team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run @@ -1407,7 +1407,7 @@ Hermes 官方公开说明里,明确把这些能力作为它的核心区别: │ ├─ provider/chat/tool loop │ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden) │ │ -│ └─ AgentLoop._record_skill_learning(...) +│ └─ AgentLoop._record_run_receipts(...) │ ├─ 构造 `RunRecord` │ ├─ 构造 `SkillEffectRecord[]` │ ├─ 默认只记录 receipts/effects,不生成学习候选 @@ -1750,7 +1750,10 @@ app-instance 镜像也已经切到新 Beaver 后端: - 当前 channel 职责很窄: - 把外部输入发布成 `InboundMessage` - 接收并投递 `OutboundMessage` + - old-style 平台字段(如 `chat_id/message_id/thread_id/raw_channel_payload`)只能在 adapter 层映射和保留 + - adapter 负责生成稳定 `session_id`,例如 `telegram:{chat_id}` / `slack:{channel_id}:{thread_ts}` - `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker + - WebSocket 是 Web 入口适配层,不是 Gateway channel;真实多渠道仍统一走 `ChannelAdapter -> MessageBus -> AgentService.handle_inbound_message(...)` 所以现在已经明确: @@ -2191,13 +2194,13 @@ app-instance 镜像也已经切到新 Beaver 后端: 1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。 2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。 3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。 -4. 未命中 skill 时创建 draft-only skill,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。 -5. draft-only skill receipt 记录 `activation_reason=generated_missing_skill`。 -6. missing skill draft 不自动 approve/publish,不进入 runtime skill catalog。 -7. plan event 写入 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report`。 +4. 未命中 skill 时创建 ephemeral guidance,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。 +5. ephemeral guidance receipt 记录 `activation_reason=ephemeral_guidance`。 +6. ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime skill catalog。 +7. plan event 写入 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report`。 8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。 9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。 -10. process view 展示 selected skills、generated draft id、ephemeral skill used,不展示 specialist agent selection。 +10. process view 展示 selected skills、ephemeral guidance id、ephemeral skill used,不展示 specialist agent selection。 11. team 部分失败时,process view 显示失败节点,但最终回答仍来自主 Agent。 12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。 13. rejected draft 不能 publish。 diff --git a/app-instance/frontend/app/(app)/cron/page.tsx b/app-instance/frontend/app/(app)/cron/page.tsx index e49913e..82676bc 100644 --- a/app-instance/frontend/app/(app)/cron/page.tsx +++ b/app-instance/frontend/app/(app)/cron/page.tsx @@ -1,419 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; -import { - Clock, - Plus, - Trash2, - Play, - RefreshCw, - Loader2, - AlertCircle, - X, -} from 'lucide-react'; -import { - listCronJobs, - addCronJob, - removeCronJob, - toggleCronJob, - runCronJob, -} from '@/lib/api'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs'; -import { Badge } from '@/components/ui/badge'; -import { Switch } from '@/components/ui/switch'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; -import { useChatStore } from '@/lib/store'; -import type { CronJob } from '@/types'; - -export default function CronPage() { - const { locale } = useAppI18n(); - const sessionId = useChatStore((s) => s.sessionId); - const [jobs, setJobs] = useState<CronJob[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const [showAdd, setShowAdd] = useState(false); - const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default'; - - const loadJobs = async () => { - setLoading(true); - setError(null); - try { - const data = await listCronJobs(true); - setJobs(data); - } catch (err: any) { - setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs')); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadJobs(); - }, []); - - const handleToggle = async (jobId: string, enabled: boolean) => { - try { - await toggleCronJob(jobId, enabled); - loadJobs(); - } catch { - // ignore - } - }; - - const handleDelete = async (jobId: string) => { - try { - await removeCronJob(jobId); - loadJobs(); - } catch { - // ignore - } - }; - - const handleRun = async (jobId: string) => { - try { - await runCronJob(jobId); - loadJobs(); - } catch { - // ignore - } - }; - - const handleAdd = async (params: { - name: string; - message: string; - every_seconds?: number; - cron_expr?: string; - }) => { - try { - await addCronJob({ - ...params, - session_key: targetSessionKey, - }); - setShowAdd(false); - loadJobs(); - } catch (err: any) { - setError(err.message); - } - }; - - const formatTime = (ms: number | null) => { - if (!ms) return '-'; - return new Date(ms).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; - - if (loading) { - return ( - <div className="flex items-center justify-center py-20"> - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> - </div> - ); - } - - return ( - <div className="max-w-5xl mx-auto p-6 space-y-6"> - <TaskManagementTabs /> - - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-bold flex items-center gap-2"> - <Clock className="w-6 h-6" /> - {pickAppText(locale, '定时任务', 'Scheduled tasks')} - </h1> - <div className="flex items-center gap-2"> - <Button onClick={loadJobs} variant="outline" size="sm"> - <RefreshCw className="w-4 h-4 mr-2" /> - {pickAppText(locale, '刷新', 'Refresh')} - </Button> - <Button onClick={() => setShowAdd(true)} size="sm"> - <Plus className="w-4 h-4 mr-2" /> - {pickAppText(locale, '新建任务', 'New job')} - </Button> - </div> - </div> - - {error && ( - <Card className="border-destructive"> - <CardContent className="pt-6"> - <div className="flex items-center gap-2 text-destructive text-sm"> - <AlertCircle className="w-4 h-4" /> - {error} - </div> - </CardContent> - </Card> - )} - - {/* Add Job Form */} - {showAdd && ( - <AddJobForm - targetSessionKey={targetSessionKey} - onAdd={handleAdd} - onCancel={() => setShowAdd(false)} - /> - )} - - {/* Jobs Table */} - <Card> - <CardContent className="p-0"> - {jobs.length === 0 ? ( - <div className="py-12 text-center text-muted-foreground"> - <Clock className="w-10 h-10 mx-auto mb-3 opacity-30" /> - <p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p> - <p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p> - </div> - ) : ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead> - <TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead> - <TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead> - <TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead> - <TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead> - <TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead> - <TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead> - <TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {jobs.map((job) => ( - <TableRow key={job.id}> - <TableCell> - <Switch - checked={job.enabled} - onCheckedChange={(checked) => - handleToggle(job.id, checked) - } - /> - </TableCell> - <TableCell className="font-medium"> - <div> - <span>{job.name}</span> - <span className="text-xs text-muted-foreground ml-2"> - {job.id} - </span> - </div> - </TableCell> - <TableCell> - <code className="text-xs bg-muted px-1.5 py-0.5 rounded"> - {job.schedule_display} - </code> - </TableCell> - <TableCell> - <span className="text-sm truncate max-w-[200px] block"> - {job.message} - </span> - </TableCell> - <TableCell className="text-xs text-muted-foreground"> - {formatTime(job.last_run_at_ms)} - </TableCell> - <TableCell className="text-xs text-muted-foreground"> - {formatTime(job.next_run_at_ms)} - </TableCell> - <TableCell> - {job.last_status === 'ok' && ( - <Badge variant="default" className="text-xs bg-green-600"> - OK - </Badge> - )} - {job.last_status === 'error' && ( - <Badge variant="destructive" className="text-xs"> - {pickAppText(locale, '错误', 'Error')} - </Badge> - )} - {!job.last_status && ( - <span className="text-xs text-muted-foreground"> - - - </span> - )} - </TableCell> - <TableCell> - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="icon" - className="h-7 w-7" - onClick={() => handleRun(job.id)} - title={pickAppText(locale, '立即执行', 'Run now')} - > - <Play className="w-3.5 h-3.5" /> - </Button> - <Button - variant="ghost" - size="icon" - className="h-7 w-7 text-destructive hover:text-destructive" - onClick={() => handleDelete(job.id)} - title={pickAppText(locale, '删除', 'Delete')} - > - <Trash2 className="w-3.5 h-3.5" /> - </Button> - </div> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </CardContent> - </Card> - </div> - ); -} - -function AddJobForm({ - targetSessionKey, - onAdd, - onCancel, -}: { - targetSessionKey: string; - onAdd: (params: { - name: string; - message: string; - every_seconds?: number; - cron_expr?: string; - }) => void; - onCancel: () => void; -}) { - const { locale } = useAppI18n(); - const [name, setName] = useState(''); - const [message, setMessage] = useState(''); - const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every'); - const [everySeconds, setEverySeconds] = useState('3600'); - const [cronExpr, setCronExpr] = useState('0 9 * * *'); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim() || !message.trim()) return; - - const params: any = { name: name.trim(), message: message.trim() }; - if (scheduleType === 'every') { - params.every_seconds = parseInt(everySeconds, 10) || 3600; - } else { - params.cron_expr = cronExpr.trim(); - } - onAdd(params); - }; - - return ( - <Card> - <CardHeader className="pb-4"> - <div className="flex items-center justify-between"> - <CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle> - <Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}> - <X className="w-4 h-4" /> - </Button> - </div> - </CardHeader> - <CardContent> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label> - <Input - id="name" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label> - <Select - value={scheduleType} - onValueChange={(v) => setScheduleType(v as 'every' | 'cron')} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem> - <SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - {scheduleType === 'every' ? ( - <div className="space-y-2"> - <Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label> - <Input - id="every" - type="number" - value={everySeconds} - onChange={(e) => setEverySeconds(e.target.value)} - min="10" - placeholder="3600" - /> - <p className="text-xs text-muted-foreground"> - {parseInt(everySeconds, 10) >= 3600 - ? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)} 分`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`) - : parseInt(everySeconds, 10) >= 60 - ? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 60)} 分 ${parseInt(everySeconds, 10) % 60} 秒`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`) - : ''} - </p> - </div> - ) : ( - <div className="space-y-2"> - <Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label> - <Input - id="cron" - value={cronExpr} - onChange={(e) => setCronExpr(e.target.value)} - placeholder="0 9 * * *" - /> - <p className="text-xs text-muted-foreground"> - {pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')} - </p> - </div> - )} - - <div className="space-y-2"> - <Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label> - <Input - id="message" - value={message} - onChange={(e) => setMessage(e.target.value)} - placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')} - /> - <p className="text-xs text-muted-foreground"> - {pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code> - </p> - </div> - - <div className="flex justify-end gap-2"> - <Button type="button" variant="outline" onClick={onCancel}> - {pickAppText(locale, '取消', 'Cancel')} - </Button> - <Button type="submit" disabled={!name.trim() || !message.trim()}> - <Plus className="w-4 h-4 mr-2" /> - {pickAppText(locale, '创建任务', 'Create job')} - </Button> - </div> - </form> - </CardContent> - </Card> - ); +export default function CronRedirectPage() { + redirect('/tasks?tab=scheduled'); } diff --git a/app-instance/frontend/app/(app)/layout.tsx b/app-instance/frontend/app/(app)/layout.tsx index 82e92af..b302291 100644 --- a/app-instance/frontend/app/(app)/layout.tsx +++ b/app-instance/frontend/app/(app)/layout.tsx @@ -1,21 +1,9 @@ -import Header from '@/components/Header'; -import AuthGuard from '@/components/AuthGuard'; -import { AppRuntimeBridge } from '@/components/AppRuntimeBridge'; +import { AppShell } from '@/components/AppShell'; export default function AppLayout({ children, }: { children: React.ReactNode; }) { - return ( - <div className="min-h-screen bg-background text-foreground"> - <Header /> - <main className="pt-16"> - <AuthGuard> - <AppRuntimeBridge /> - {children} - </AuthGuard> - </main> - </div> - ); + return <AppShell>{children}</AppShell>; } diff --git a/app-instance/frontend/app/(app)/logs/page.tsx b/app-instance/frontend/app/(app)/logs/page.tsx new file mode 100644 index 0000000..281d953 --- /dev/null +++ b/app-instance/frontend/app/(app)/logs/page.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, Bot, Braces, ChevronDown, Loader2, MessageSquare, RefreshCw, TerminalSquare } from 'lucide-react'; + +import { getChatLogs } from '@/lib/api'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import type { ChatLogEvent, ChatLogSession } from '@/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; + +function eventLabel(event: ChatLogEvent): string { + return event.event_type || event.role || 'event'; +} + +function eventIcon(event: ChatLogEvent) { + if (event.event_type === 'llm_request_snapshotted') return Braces; + if (event.role === 'assistant') return Bot; + if (event.role === 'tool') return TerminalSquare; + return MessageSquare; +} + +function formatPayload(value: unknown): string { + if (value === null || value === undefined || value === '') return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function eventBody(event: ChatLogEvent): string { + const content = event.content?.trim(); + if (content) return content; + return formatPayload(event.event_payload); +} + +function timestampLabel(value?: string | null): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +export default function LogsPage() { + const { locale } = useAppI18n(); + const [sessions, setSessions] = useState<ChatLogSession[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [expandedRuns, setExpandedRuns] = useState<Set<string>>(() => new Set()); + + const runs = useMemo( + () => + sessions.flatMap((session) => + session.runs.map((run) => ({ + ...run, + sessionTitle: session.title || session.session_id, + })) + ), + [sessions] + ); + + const loadLogs = async () => { + setLoading(true); + setError(null); + try { + const data = await getChatLogs(80); + setSessions(data.sessions || []); + const firstRun = data.sessions?.[0]?.runs?.[0]?.run_id; + if (firstRun) { + setExpandedRuns((current) => (current.size ? current : new Set([firstRun]))); + } + } catch (err: any) { + setError(err.message || pickAppText(locale, '读取日志失败', 'Failed to load logs')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadLogs(); + }, []); + + const toggleRun = (runId: string) => { + setExpandedRuns((current) => { + const next = new Set(current); + if (next.has(runId)) next.delete(runId); + else next.add(runId); + return next; + }); + }; + + return ( + <div className="mx-auto max-w-7xl p-6 space-y-6"> + <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> + <div> + <h1 className="text-2xl font-bold">{pickAppText(locale, '运行日志', 'Runtime Logs')}</h1> + <p className="mt-1 text-sm text-muted-foreground"> + {pickAppText( + locale, + '按每一次用户输入分组,查看 LLM 请求、回复、工具结果和隐藏运行快照。', + 'Grouped by each user input, with LLM requests, responses, tool results, and hidden runtime snapshots.' + )} + </p> + </div> + <Button onClick={loadLogs} variant="outline" size="sm" disabled={loading}> + {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />} + {pickAppText(locale, '刷新', 'Refresh')} + </Button> + </div> + + {error ? ( + <Card className="border-destructive"> + <CardContent className="flex items-start gap-3 pt-6 text-destructive"> + <AlertCircle className="mt-0.5 h-5 w-5" /> + <div> + <p className="text-sm font-medium">{pickAppText(locale, '无法读取日志', 'Unable to load logs')}</p> + <p className="mt-1 text-sm text-muted-foreground">{error}</p> + </div> + </CardContent> + </Card> + ) : null} + + {loading && !runs.length ? ( + <div className="flex items-center justify-center py-20"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : null} + + {!loading && !runs.length && !error ? ( + <Card> + <CardContent className="py-12 text-center text-sm text-muted-foreground"> + {pickAppText(locale, '还没有可展示的运行日志。', 'No runtime logs are available yet.')} + </CardContent> + </Card> + ) : null} + + <div className="space-y-3"> + {runs.map((run) => { + const expanded = expandedRuns.has(run.run_id); + return ( + <Card key={`${run.session_id}:${run.run_id}`} className="overflow-hidden"> + <button + type="button" + onClick={() => toggleRun(run.run_id)} + className="flex w-full items-start justify-between gap-4 border-b px-5 py-4 text-left transition-colors hover:bg-muted/40" + > + <span className="min-w-0 space-y-2"> + <span className="flex flex-wrap items-center gap-2"> + <Badge variant={run.task_mode ? 'default' : 'secondary'}> + {run.task_mode ? 'Task' : 'Chat'} + </Badge> + {run.source ? <Badge variant="outline">{run.source}</Badge> : null} + {run.attempt_index ? <Badge variant="outline">attempt {run.attempt_index}</Badge> : null} + <span className="text-xs text-muted-foreground">{timestampLabel(run.started_at)}</span> + </span> + <span className="block truncate text-sm font-semibold text-foreground"> + {run.user_input || run.title || run.run_id} + </span> + <span className="block truncate text-xs text-muted-foreground"> + {run.sessionTitle} · {run.run_id} + </span> + </span> + <ChevronDown className={`mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} /> + </button> + + {expanded ? ( + <CardContent className="space-y-3 p-5"> + {run.events.map((event, index) => { + const Icon = eventIcon(event); + const body = eventBody(event); + return ( + <div + key={`${event.message_id ?? index}:${event.event_type}`} + className="rounded-lg border border-border bg-background" + > + <div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2"> + <div className="flex min-w-0 items-center gap-2"> + <Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> + <span className="truncate text-sm font-medium">{eventLabel(event)}</span> + <Badge variant={event.context_visible ? 'secondary' : 'outline'}> + {event.context_visible ? 'visible' : 'hidden'} + </Badge> + {event.tool_name ? <Badge variant="outline">{event.tool_name}</Badge> : null} + </div> + <span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span> + </div> + <pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground"> + {body || formatPayload(event)} + </pre> + </div> + ); + })} + </CardContent> + ) : null} + </Card> + ); + })} + </div> + </div> + ); +} diff --git a/app-instance/frontend/app/(app)/marketplace/page.tsx b/app-instance/frontend/app/(app)/marketplace/page.tsx index 5b5c257..a17adc5 100644 --- a/app-instance/frontend/app/(app)/marketplace/page.tsx +++ b/app-instance/frontend/app/(app)/marketplace/page.tsx @@ -1,439 +1,256 @@ 'use client'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react'; + import { - Store, - RefreshCw, - Loader2, - AlertCircle, - Plus, - Trash2, - Download, - Check, - X, - Globe, - FolderOpen, -} from 'lucide-react'; -import { - listMarketplaces, - addMarketplace, - removeMarketplace, - updateMarketplace, - listMarketplacePlugins, - installMarketplacePlugin, - uninstallPlugin, + getSkillHubDetail, + getSkillHubVersion, + installSkillHubSkill, + searchSkillHubSkills, } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; -import type { Marketplace, MarketplacePlugin } from '@/types'; +import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +type SortMode = 'relevance' | 'downloads' | 'newest'; + +function publishedVersion(skill: SkillHubSearchItem | null): string { + return skill?.publishedVersion?.version || skill?.headlineVersion?.version || ''; +} + export default function MarketplacePage() { const { locale } = useAppI18n(); - const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]); - const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null); - const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]); + const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]); + const [query, setQuery] = useState(''); + const [sort, setSort] = useState<SortMode>('newest'); + const [starredOnly, setStarredOnly] = useState(false); + const [page, setPage] = useState(0); + const [items, setItems] = useState<SkillHubSearchItem[]>([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); - const [pluginsLoading, setPluginsLoading] = useState(false); const [error, setError] = useState<string | null>(null); - const [showAddForm, setShowAddForm] = useState(false); - const [addSource, setAddSource] = useState(''); - const [adding, setAdding] = useState(false); - const [actionPlugin, setActionPlugin] = useState<string | null>(null); - const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null); + const [selected, setSelected] = useState<SkillHubSearchItem | null>(null); + const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [installing, setInstalling] = useState(false); - const loadMarketplaces = useCallback(async () => { + const load = useCallback(async () => { setLoading(true); setError(null); try { - const data = await listMarketplaces(); - const list = Array.isArray(data) ? data : []; - setMarketplaces(list); - // Auto-select first marketplace if none selected or selected was removed - if (list.length > 0) { - setSelectedMarketplace((prev) => { - if (prev && list.some((m) => m.name === prev)) return prev; - return list[0].name; - }); - } else { - setSelectedMarketplace(null); - setPlugins([]); - } + const result = await searchSkillHubSkills({ q: query, sort, page, size: 12 }); + const nextItems = Array.isArray(result.items) ? result.items : []; + setItems(starredOnly ? nextItems.filter((item) => (item.starCount || 0) > 0) : nextItems); + setTotal(result.total || 0); } catch (err: any) { - setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces')); + setError(err.message || t('加载 SkillHub 失败', 'Failed to load SkillHub')); } finally { setLoading(false); } - }, []); - - const loadPlugins = useCallback(async (marketplaceName: string) => { - setPluginsLoading(true); - try { - const data = await listMarketplacePlugins(marketplaceName); - setPlugins(Array.isArray(data) ? data : []); - } catch (err: any) { - setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins')); - } finally { - setPluginsLoading(false); - } - }, []); + }, [page, query, sort, starredOnly, t]); useEffect(() => { - loadMarketplaces(); - }, [loadMarketplaces]); + void load(); + }, [load]); - useEffect(() => { - if (selectedMarketplace) { - loadPlugins(selectedMarketplace); - } - }, [selectedMarketplace, loadPlugins]); - - const handleAdd = async () => { - if (!addSource.trim()) return; - setAdding(true); + const openDetail = async (item: SkillHubSearchItem) => { + setSelected(item); + setVersionDetail(null); + setDetailLoading(true); setError(null); try { - const marketplace = await addMarketplace(addSource.trim()); - setAddSource(''); - setShowAddForm(false); - await loadMarketplaces(); - setSelectedMarketplace(marketplace.name); - } catch (err: any) { - setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace')); - } finally { - setAdding(false); - } - }; - - const handleRemove = async (name: string) => { - setError(null); - try { - await removeMarketplace(name); - if (selectedMarketplace === name) { - setSelectedMarketplace(null); - setPlugins([]); - } - await loadMarketplaces(); - } catch (err: any) { - setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace')); - } - }; - - const handleUpdateMarketplace = async (name: string) => { - setUpdatingMarketplace(name); - setError(null); - try { - await updateMarketplace(name); - await loadPlugins(name); - } catch (err: any) { - setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace')); - } finally { - setUpdatingMarketplace(null); - } - }; - - const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => { - setActionPlugin(pluginName); - setError(null); - try { - await installMarketplacePlugin(marketplaceName, pluginName); - await loadPlugins(marketplaceName); - } catch (err: any) { - setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin')); - } finally { - setActionPlugin(null); - } - }; - - const handleInstall = async (marketplaceName: string, pluginName: string) => { - setActionPlugin(pluginName); - setError(null); - try { - await installMarketplacePlugin(marketplaceName, pluginName); - await loadPlugins(marketplaceName); - } catch (err: any) { - setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin')); - } finally { - setActionPlugin(null); - } - }; - - const handleUninstall = async (pluginName: string) => { - setActionPlugin(pluginName); - setError(null); - try { - await uninstallPlugin(pluginName); - if (selectedMarketplace) { - await loadPlugins(selectedMarketplace); + const detail = await getSkillHubDetail(item.namespace, item.slug); + setSelected(detail); + const version = publishedVersion(detail); + if (version) { + setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version)); } } catch (err: any) { - setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin')); + setError(err.message || t('加载技能详情失败', 'Failed to load skill details')); } finally { - setActionPlugin(null); + setDetailLoading(false); } }; - const handleRefresh = async () => { - await loadMarketplaces(); - if (selectedMarketplace) { - await loadPlugins(selectedMarketplace); + const installSelected = async () => { + if (!selected) return; + setInstalling(true); + setError(null); + try { + const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected)); + setSelected({ ...selected, installed: true, installed_version: result.version }); + await load(); + } catch (err: any) { + setError(err.message || t('安装技能失败', 'Failed to install skill')); + } finally { + setInstalling(false); } }; - if (loading) { - return ( - <div className="flex items-center justify-center py-20"> - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> - </div> - ); - } + const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]); return ( - <div className="max-w-5xl mx-auto p-6 space-y-6"> - {/* Page header */} - <div className="flex items-center justify-between"> - <div> - <h1 className="text-2xl font-bold flex items-center gap-2"> - <Store className="w-6 h-6" /> - {pickAppText(locale, '插件市场', 'Plugin marketplace')} - </h1> - <p className="text-sm text-muted-foreground mt-1"> - {pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')} - </p> - </div> - <div className="flex items-center gap-2"> - <Button - onClick={() => setShowAddForm((v) => !v)} - variant="outline" - size="sm" - > - <Plus className="w-4 h-4 mr-2" /> - {pickAppText(locale, '添加市场', 'Add marketplace')} + <div className="mx-auto max-w-7xl p-6"> + <div className="mx-auto mb-10 max-w-4xl"> + <form + className="flex gap-3" + onSubmit={(event) => { + event.preventDefault(); + setPage(0); + void load(); + }} + > + <div className="relative flex-1"> + <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" /> + <Input + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder={t('搜索技能...', 'Search skills...')} + className="h-14 rounded-2xl pl-12 text-base" + /> + </div> + <Button type="submit" className="h-14 rounded-2xl px-10 text-base"> + {t('搜索', 'Search')} </Button> - <Button onClick={handleRefresh} variant="outline" size="sm"> - <RefreshCw className="w-4 h-4 mr-2" /> - {pickAppText(locale, '刷新', 'Refresh')} - </Button> - </div> + </form> </div> - {/* Error */} {error && ( - <Card className="border-destructive"> - <CardContent className="pt-6"> - <div className="flex items-center justify-between gap-2 text-destructive text-sm"> - <div className="flex items-center gap-2"> - <AlertCircle className="w-4 h-4 shrink-0" /> - {error} + <Card className="mb-6 border-destructive"> + <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive"> + <AlertCircle className="h-4 w-4" /> + {error} + </CardContent> + </Card> + )} + + {selected ? ( + <div className="space-y-5"> + <Button variant="ghost" onClick={() => setSelected(null)}> + <ArrowLeft className="mr-2 h-4 w-4" /> + {t('返回搜索', 'Back to search')} + </Button> + <Card> + <CardHeader> + <div className="flex flex-wrap items-start justify-between gap-4"> + <div> + <div className="mb-2 flex items-center gap-2"> + <Badge variant="outline">@{selected.namespace}</Badge> + {selected.installed && ( + <Badge variant="secondary" className="gap-1"> + <Check className="h-3 w-3" /> + {t('已安装', 'Installed')} + </Badge> + )} + </div> + <CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle> + <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p> + </div> + <Button onClick={installSelected} disabled={installing || detailLoading}> + {installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />} + {selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')} + </Button> </div> - <Button - variant="ghost" - size="sm" - className="shrink-0 h-6 w-6 p-0" - onClick={() => setError(null)} - > - <X className="w-4 h-4" /> - </Button> - </div> - </CardContent> - </Card> - )} - - {/* Add marketplace form */} - {showAddForm && ( - <Card> - <CardContent className="pt-6"> - <div className="flex items-center gap-2"> - <Input - placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...)', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')} - value={addSource} - onChange={(e) => setAddSource(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAdd(); - }} - disabled={adding} - className="flex-1" - /> - <Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm"> - {adding ? ( - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - ) : ( - <Plus className="w-4 h-4 mr-2" /> - )} - {pickAppText(locale, '添加', 'Add')} - </Button> - <Button - onClick={() => { - setShowAddForm(false); - setAddSource(''); - }} - variant="ghost" - size="sm" - > - {pickAppText(locale, '取消', 'Cancel')} - </Button> - </div> - </CardContent> - </Card> - )} - - {/* Marketplace tabs */} - {marketplaces.length > 0 && ( - <div className="flex items-center gap-2 flex-wrap"> - {marketplaces.map((marketplace) => ( - <div key={marketplace.name} className="flex items-center gap-0.5"> - <Button - variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'} - size="sm" - onClick={() => setSelectedMarketplace(marketplace.name)} - className="gap-1.5" - > - {marketplace.type === 'git' ? ( - <Globe className="w-3.5 h-3.5" /> - ) : ( - <FolderOpen className="w-3.5 h-3.5" /> - )} - {marketplace.name} - </Button> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 text-muted-foreground hover:text-primary" - disabled={updatingMarketplace === marketplace.name} - onClick={() => handleUpdateMarketplace(marketplace.name)} - title={pickAppText(locale, '更新市场', 'Update marketplace')} - > - {updatingMarketplace === marketplace.name ? ( - <Loader2 className="w-3.5 h-3.5 animate-spin" /> - ) : ( - <RefreshCw className="w-3.5 h-3.5" /> - )} - </Button> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" - onClick={() => handleRemove(marketplace.name)} - > - <Trash2 className="w-3.5 h-3.5" /> - </Button> - </div> - ))} - </div> - )} - - {/* Empty state */} - {marketplaces.length === 0 && !error && ( - <Card> - <CardContent className="py-16 text-center text-muted-foreground"> - <Store className="w-12 h-12 mx-auto mb-4 opacity-30" /> - <p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p> - <p className="text-sm mt-2 max-w-sm mx-auto"> - {pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')} - </p> - </CardContent> - </Card> - )} - - {/* Plugin list */} - {selectedMarketplace && ( - <> - {pluginsLoading ? ( - <div className="flex items-center justify-center py-12"> - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> - </div> - ) : plugins.length === 0 ? ( - <Card> - <CardContent className="py-12 text-center text-muted-foreground"> - <Store className="w-10 h-10 mx-auto mb-3 opacity-30" /> - <p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p> - <p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p> - </CardContent> - </Card> - ) : ( - <div className="space-y-4"> - {plugins.map((plugin) => ( - <Card key={plugin.name}> - <CardHeader className="pb-3"> - <div className="flex items-start justify-between gap-4"> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2 flex-wrap"> - <CardTitle className="text-base font-semibold"> - {plugin.name} - </CardTitle> - {plugin.installed && ( - <Badge variant="secondary" className="text-xs gap-1"> - <Check className="w-3 h-3" /> - {pickAppText(locale, '已安装', 'Installed')} - </Badge> - )} - </div> - {plugin.description && ( - <p className="text-sm text-muted-foreground mt-1 leading-relaxed"> - {plugin.description} - </p> - )} - </div> - <div className="shrink-0 flex items-center gap-2"> - {plugin.installed ? ( - <> - <Button - variant="outline" - size="sm" - disabled={actionPlugin === plugin.name} - onClick={() => - handleUpdatePlugin(plugin.marketplace_name, plugin.name) - } - > - {actionPlugin === plugin.name ? ( - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - ) : ( - <RefreshCw className="w-4 h-4 mr-2" /> - )} - {pickAppText(locale, '更新', 'Update')} - </Button> - <Button - variant="outline" - size="sm" - disabled={actionPlugin === plugin.name} - onClick={() => handleUninstall(plugin.name)} - > - {actionPlugin === plugin.name ? ( - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - ) : ( - <Trash2 className="w-4 h-4 mr-2" /> - )} - {pickAppText(locale, '卸载', 'Uninstall')} - </Button> - </> - ) : ( - <Button - variant="default" - size="sm" - disabled={actionPlugin === plugin.name} - onClick={() => - handleInstall(plugin.marketplace_name, plugin.name) - } - > - {actionPlugin === plugin.name ? ( - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - ) : ( - <Download className="w-4 h-4 mr-2" /> - )} - {pickAppText(locale, '安装', 'Install')} - </Button> - )} + </CardHeader> + <CardContent className="space-y-4"> + {detailLoading ? ( + <div className="flex justify-center py-10"> + <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> + </div> + ) : ( + <> + <div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge> + <span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span> + <span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span> + </div> + <div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]"> + <div className="rounded-lg border border-border bg-muted/20 p-4"> + <div className="mb-2 text-sm font-medium">SKILL.md</div> + <pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs"> + {versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')} + </pre> + </div> + <div className="rounded-lg border border-border bg-muted/20 p-4"> + <div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div> + <div className="space-y-2"> + {(versionDetail?.files || []).map((file) => ( + <div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs"> + <span className="break-all font-mono">{file.filePath}</span> + <span className="shrink-0 text-muted-foreground">{file.fileSize} B</span> + </div> + ))} </div> </div> + </div> + </> + )} + </CardContent> + </Card> + </div> + ) : ( + <div className="space-y-6"> + <div className="flex flex-wrap items-center gap-3"> + <span className="text-sm font-medium text-muted-foreground">{t('排序:', 'Sort:')}</span> + {([ + ['relevance', t('相关性', 'Relevance')], + ['downloads', t('下载量', 'Downloads')], + ['newest', t('最新', 'Newest')], + ] as Array<[SortMode, string]>).map(([value, label]) => ( + <Button key={value} size="sm" variant={sort === value ? 'default' : 'outline'} onClick={() => { setSort(value); setPage(0); }}> + {label} + </Button> + ))} + <span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span> + <Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}> + <Star className="mr-2 h-4 w-4" /> + {t('只看已收藏', 'Starred only')} + </Button> + </div> + + {loading ? ( + <div className="flex justify-center py-20"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : ( + <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3"> + {items.map((item) => ( + <Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}> + <CardHeader> + <div className="flex items-start justify-between gap-3"> + <CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle> + <Badge variant="outline">@{item.namespace}</Badge> + </div> </CardHeader> + <CardContent className="space-y-5"> + <p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p> + <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> + <Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge> + <span>{item.downloadCount || 0}</span> + <span>{item.starCount || 0}</span> + {item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>} + </div> + </CardContent> </Card> ))} </div> )} - </> + + <div className="flex items-center justify-center gap-3"> + <Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}> + {t('上一页', 'Previous')} + </Button> + <span className="text-sm text-muted-foreground">{page + 1} / {totalPages}</span> + <Button variant="outline" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}> + {t('下一页', 'Next')} + </Button> + </div> + </div> )} </div> ); diff --git a/app-instance/frontend/app/(app)/mcp/page.tsx b/app-instance/frontend/app/(app)/mcp/page.tsx index cfb8348..08ac84e 100644 --- a/app-instance/frontend/app/(app)/mcp/page.tsx +++ b/app-instance/frontend/app/(app)/mcp/page.tsx @@ -116,6 +116,7 @@ export default function MCPPage() { const [form, setForm] = useState(createEmptyForm()); const [authzStatus, setAuthzStatus] = useState<AuthzStatus | null>(null); const [selectedServerId, setSelectedServerId] = useState<string | null>(null); + const [toolTab, setToolTab] = useState<'local' | 'online'>('local'); const load = useCallback(async (background = false) => { if (background) { @@ -262,6 +263,7 @@ export default function MCPPage() { const showAuthzPreview = form.auth_mode === 'oauth_backend_token'; const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null; const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null; + const visibleServers = servers.filter((server) => (server.kind || (server.transport === 'stdio' ? 'local' : 'online')) === toolTab); let authzHint = t( '无需手动填写。Audience 会按 MCP ID 自动生成,Scopes 按 AuthZ 当前权限动态决定。', 'No manual input is required. The audience is generated from the MCP ID and scopes follow current AuthZ permissions.' @@ -305,10 +307,10 @@ export default function MCPPage() { <div> <h1 className="text-2xl font-bold flex items-center gap-2"> <ServerCog className="w-6 h-6" /> - {t('MCP 服务', 'MCP servers')} + {t('工具', 'Tools')} </h1> <p className="text-sm text-muted-foreground mt-1"> - {t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')} + {t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')} </p> </div> <div className="flex items-center gap-2"> @@ -323,7 +325,7 @@ export default function MCPPage() { <DialogTrigger asChild> <Button size="sm"> <Plus className="w-4 h-4 mr-2" /> - {t('新增 MCP', 'Add MCP')} + {t('新增工具服务', 'Add tool server')} </Button> </DialogTrigger> <DialogContent className="sm:max-w-2xl"> @@ -484,9 +486,19 @@ export default function MCPPage() { </Card> )} + <Tabs value={toolTab} onValueChange={(value) => { + setToolTab(value as 'local' | 'online'); + setSelectedServerId(null); + }} className="space-y-4"> + <TabsList> + <TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger> + <TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger> + </TabsList> + </Tabs> + <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4"> <div className="space-y-4"> - {servers.map((server) => ( + {visibleServers.map((server) => ( <Card key={server.id} role="button" @@ -511,6 +523,8 @@ export default function MCPPage() { </div> <div className="flex items-center gap-2 flex-wrap justify-end"> <Badge variant="outline">{transportLabel(server.transport, locale)}</Badge> + <Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge> + {server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>} <Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}> {serverStatusLabel(server.status, locale)} </Badge> @@ -534,12 +548,14 @@ export default function MCPPage() { {server.last_error && <span className="text-rose-300">{server.last_error}</span>} </div> <div className="flex items-center gap-2 justify-end"> - <Button variant="outline" size="sm" onClick={(event) => { - event.stopPropagation(); - openEdit(server); - }}> - {t('编辑', 'Edit')} - </Button> + {!server.managed && ( + <Button variant="outline" size="sm" onClick={(event) => { + event.stopPropagation(); + openEdit(server); + }}> + {t('编辑', 'Edit')} + </Button> + )} <Button variant="outline" size="sm" onClick={(event) => { event.stopPropagation(); void handleTest(server.id); @@ -547,21 +563,23 @@ export default function MCPPage() { {testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />} {t('测试', 'Test')} </Button> - <Button variant="outline" size="sm" onClick={(event) => { - event.stopPropagation(); - void handleDelete(server.id); - }}> - <Trash2 className="w-4 h-4 mr-2" /> - {t('删除', 'Delete')} - </Button> + {!server.managed && ( + <Button variant="outline" size="sm" onClick={(event) => { + event.stopPropagation(); + void handleDelete(server.id); + }}> + <Trash2 className="w-4 h-4 mr-2" /> + {t('删除', 'Delete')} + </Button> + )} </div> </CardContent> </Card> ))} - {servers.length === 0 && ( + {visibleServers.length === 0 && ( <Card> <CardContent className="py-12 text-center text-muted-foreground"> - {t('暂无 MCP 服务。', 'There are no MCP servers yet.')} + {toolTab === 'local' ? t('暂无本地工具服务。', 'There are no local tool servers yet.') : t('暂无在线工具服务。', 'There are no online tool servers yet.')} </CardContent> </Card> )} @@ -571,7 +589,7 @@ export default function MCPPage() { <CardHeader> <CardTitle className="text-base flex items-center gap-2"> <Wrench className="w-4 h-4" /> - {selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')} + {selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')} </CardTitle> </CardHeader> <CardContent className="space-y-4"> diff --git a/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx b/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx new file mode 100644 index 0000000..0d95b26 --- /dev/null +++ b/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { ArrowLeft, Check, Loader2, RefreshCw, Send, Settings2 } from 'lucide-react'; + +import { getNotification, sendMessage } from '@/lib/api'; +import type { ChatMessage, NotificationDetail } from '@/types'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench'; + +type ReplyIntent = 'revise_once' | 'update_future'; + +export default function NotificationDetailPage() { + const { locale } = useAppI18n(); + const params = useParams<{ scheduledRunId: string }>(); + const scheduledRunId = decodeURIComponent(params.scheduledRunId); + const [detail, setDetail] = useState<NotificationDetail | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [intent, setIntent] = useState<ReplyIntent | null>(null); + const [input, setInput] = useState(''); + const [submitting, setSubmitting] = useState(false); + const messagesEndRef = useRef<HTMLDivElement>(null); + const viewportRef = useRef<HTMLDivElement>(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setDetail(await getNotification(scheduledRunId)); + } catch (err: any) { + setError(err.message || pickAppText(locale, '加载通知详情失败', 'Failed to load notification detail')); + } finally { + setLoading(false); + } + }, [locale, scheduledRunId]); + + useEffect(() => { + void load(); + }, [load]); + + const messages = useMemo<ChatMessage[]>(() => { + if (!detail) return []; + const runId = detail.run_id; + const scoped = detail.detail.messages.filter((message) => !runId || message.run_id === runId || message.scheduled_run_id === detail.scheduled_run_id); + if (scoped.length > 0) return scoped; + return [ + { role: 'user', content: detail.message, timestamp: detail.started_at || undefined }, + { role: 'assistant', content: detail.output || detail.error || '', timestamp: detail.finished_at || detail.started_at || undefined, run_id: detail.run_id || undefined, task_id: detail.task_id || null }, + ]; + }, [detail]); + + const formatTime = (value?: string | null) => { + if (!value) return '-'; + return new Date(value).toLocaleString(locale); + }; + + const submitReply = async () => { + if (!detail || !intent || !input.trim() || submitting) return; + setSubmitting(true); + setError(null); + try { + await sendMessage( + input.trim(), + detail.notification_session_id, + undefined, + { + replyToScheduledRunId: detail.scheduled_run_id, + scheduledReplyIntent: intent, + } + ); + setInput(''); + await load(); + } catch (err: any) { + setError(err.message || pickAppText(locale, '提交修改失败', 'Failed to submit revision')); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( + <main className="flex h-[calc(100vh-4rem)] items-center justify-center text-muted-foreground"> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {pickAppText(locale, '加载中', 'Loading')} + </main> + ); + } + + if (!detail) { + return ( + <main className="mx-auto max-w-4xl px-6 py-8"> + <Link href="/notifications" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"> + <ArrowLeft className="h-4 w-4" /> + {pickAppText(locale, '返回通知', 'Back to notifications')} + </Link> + <p className="mt-8 text-destructive">{error || pickAppText(locale, '通知不存在', 'Notification not found')}</p> + </main> + ); + } + + return ( + <main className="flex h-[calc(100vh-4rem)] flex-col bg-background"> + <div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-6 py-4"> + <div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3"> + <div className="min-w-0"> + <Link href="/notifications" className="mb-2 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"> + <ArrowLeft className="h-4 w-4" /> + {pickAppText(locale, '通知列表', 'Notifications')} + </Link> + <div className="flex min-w-0 flex-wrap items-center gap-2"> + <h1 className="truncate text-xl font-semibold">{detail.title || detail.job_name}</h1> + <Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'}>{detail.status}</Badge> + {detail.engaged && <Badge>{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>} + </div> + <p className="mt-1 text-sm text-muted-foreground"> + {pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)} + </p> + </div> + <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" onClick={() => void load()}> + <RefreshCw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '刷新', 'Refresh')} + </Button> + {detail.task_id && ( + <Button asChild size="sm"> + <Link href={`/tasks/${encodeURIComponent(detail.task_id)}`}>{pickAppText(locale, '查看任务', 'Open task')}</Link> + </Button> + )} + </div> + </div> + </div> + + {error && <div className="mx-auto w-full max-w-6xl px-6 pt-3 text-sm text-destructive">{error}</div>} + + <div className="min-h-0 flex-1"> + <ChatWorkbench + messages={messages} + isThinking={submitting} + messagesEndRef={messagesEndRef} + messageViewportRef={viewportRef} + processRuns={[]} + processEvents={[]} + processArtifacts={[]} + selectedRunId={null} + onSelectRun={() => {}} + onCancelRun={() => {}} + onFeedback={() => {}} + /> + </div> + + <div className="border-t border-[#E6E1DE] bg-background px-6 py-4"> + <div className="mx-auto max-w-5xl"> + <div className="mb-2 flex flex-wrap gap-2"> + <Button + type="button" + size="sm" + variant={intent === 'revise_once' ? 'default' : 'outline'} + onClick={() => setIntent('revise_once')} + > + <RefreshCw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '修改这次', 'Revise this')} + </Button> + <Button + type="button" + size="sm" + variant={intent === 'update_future' ? 'default' : 'outline'} + onClick={() => setIntent('update_future')} + > + <Settings2 className="mr-2 h-4 w-4" /> + {pickAppText(locale, '以后按这样', 'Apply going forward')} + </Button> + {detail.engaged && ( + <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> + <Check className="h-3.5 w-3.5" /> + {pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')} + </span> + )} + </div> + {intent && ( + <div className="rounded-[20px] border border-[#E6E1DE] bg-white p-3 shadow-[0_6px_18px_rgba(0,0,0,0.06)]"> + <textarea + value={input} + onChange={(event) => setInput(event.target.value)} + placeholder={ + intent === 'update_future' + ? pickAppText(locale, '告诉我以后这类通知要怎么调整...', 'Describe how future notifications should change...') + : pickAppText(locale, '告诉我这次内容要怎么改...', 'Describe how this result should change...') + } + className="block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none" + /> + <div className="flex justify-end"> + <Button size="sm" onClick={() => void submitReply()} disabled={!input.trim() || submitting}> + {submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />} + {pickAppText(locale, '发送', 'Send')} + </Button> + </div> + </div> + )} + </div> + </div> + </main> + ); +} diff --git a/app-instance/frontend/app/(app)/notifications/page.tsx b/app-instance/frontend/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..3b5ba88 --- /dev/null +++ b/app-instance/frontend/app/(app)/notifications/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { AlertCircle, Bell, Clock3, Loader2, RefreshCw, ArrowRight } from 'lucide-react'; + +import { listNotifications } from '@/lib/api'; +import type { NotificationRun } from '@/types'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; + +export default function NotificationsPage() { + const { locale } = useAppI18n(); + const [items, setItems] = useState<NotificationRun[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setItems(await listNotifications()); + } catch (err: any) { + setError(err.message || pickAppText(locale, '加载通知失败', 'Failed to load notifications')); + } finally { + setLoading(false); + } + }, [locale]); + + useEffect(() => { + void load(); + }, [load]); + + const formatTime = (value?: string | null) => { + if (!value) return '-'; + return new Date(value).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + }; + + return ( + <main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-6 py-8"> + <div className="mb-6 flex flex-wrap items-center justify-between gap-3"> + <div> + <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight"> + <Bell className="h-5 w-5" /> + {pickAppText(locale, '通知', 'Notifications')} + </h1> + <p className="mt-1 text-sm text-muted-foreground"> + {pickAppText(locale, '定时任务生成的日报、提醒和总结会固定出现在这里。', 'Scheduled reports, reminders, and summaries appear here.')} + </p> + </div> + <Button onClick={() => void load()} variant="outline" size="sm"> + <RefreshCw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '刷新', 'Refresh')} + </Button> + </div> + + {error && ( + <Card className="mb-4 border-destructive"> + <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive"> + <AlertCircle className="h-4 w-4" /> + {error} + </CardContent> + </Card> + )} + + <div className="min-h-0 flex-1 overflow-auto rounded-lg border border-[#E6E1DE] bg-white"> + {loading ? ( + <div className="flex h-full items-center justify-center text-muted-foreground"> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {pickAppText(locale, '加载中', 'Loading')} + </div> + ) : items.length === 0 ? ( + <div className="flex h-full flex-col items-center justify-center text-muted-foreground"> + <Bell className="mb-3 h-10 w-10 opacity-40" /> + <p className="font-medium">{pickAppText(locale, '暂无通知', 'No notifications yet')}</p> + </div> + ) : ( + <div className="divide-y divide-[#E6E1DE]"> + {items.map((item) => ( + <Link + key={item.scheduled_run_id} + href={`/notifications/${encodeURIComponent(item.scheduled_run_id)}`} + className="grid gap-3 px-5 py-4 transition-colors hover:bg-[#F7F6F5] md:grid-cols-[minmax(0,1fr)_180px_110px_24px]" + > + <div className="min-w-0"> + <div className="flex min-w-0 items-center gap-2"> + <span className="truncate font-medium">{item.title || item.job_name}</span> + {item.engaged && <Badge variant="secondary">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>} + {item.status === 'error' && <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>} + </div> + <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{item.output || item.message}</p> + </div> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Clock3 className="h-4 w-4" /> + {formatTime(item.started_at)} + </div> + <div className="text-sm text-muted-foreground">{item.job_name}</div> + <ArrowRight className="hidden h-4 w-4 self-center text-muted-foreground md:block" /> + </Link> + ))} + </div> + )} + </div> + </main> + ); +} diff --git a/app-instance/frontend/app/(app)/office/[taskId]/page.tsx b/app-instance/frontend/app/(app)/office/[taskId]/page.tsx index 269eff3..dbb22b6 100644 --- a/app-instance/frontend/app/(app)/office/[taskId]/page.tsx +++ b/app-instance/frontend/app/(app)/office/[taskId]/page.tsx @@ -1,653 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import React from 'react'; -import { - ArrowLeft, - ArrowRight, - Boxes, - FolderOutput, - ListTree, - MessageSquare, - PanelRightOpen, - Siren, - Users, -} from 'lucide-react'; - -import { - OfficeStatusBadge, - formatOfficeDuration, - formatOfficeTime, - progressPercent, -} from '@/components/office/OfficeShared'; -import { OfficePhaserCanvas } from '@/components/office/OfficePhaserCanvas'; -import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office'; -import { appEventKindLabel } from '@/lib/i18n/common'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; -import { useChatStore } from '@/lib/store'; - -function traceMetadataLabels(locale: 'zh-CN' | 'en-US'): Record<string, string> { - return { - stage_label: pickAppText(locale, '阶段', 'Stage'), - source: pickAppText(locale, '来源', 'Source'), - phase: 'Phase', - step: 'Step', - selection_mode: pickAppText(locale, '选人方式', 'Selection mode'), - selected_mode: pickAppText(locale, '选中模式', 'Selected mode'), - execution_mode: pickAppText(locale, '执行模式', 'Execution mode'), - selected_targets: pickAppText(locale, '成员', 'Members'), - selected_count: pickAppText(locale, '成员数', 'Member count'), - requested_targets: pickAppText(locale, '请求成员', 'Requested targets'), - planned_targets: pickAppText(locale, '计划成员', 'Planned targets'), - matched_procedure_id: pickAppText(locale, '命中 Procedure', 'Matched procedure'), - candidate_procedure_id: pickAppText(locale, '候选 Procedure', 'Candidate procedure'), - announcement_path: pickAppText(locale, '回流路径', 'Announcement path'), - announcement_sender_id: pickAppText(locale, '回流 Sender', 'Announcement sender'), - announcement_category: pickAppText(locale, '回流类别', 'Announcement category'), - external_fallback_reason: pickAppText(locale, '外部回退原因', 'External fallback reason'), - failure_type: pickAppText(locale, '失败分类', 'Failure type'), - failure_reason: pickAppText(locale, '失败原因', 'Failure reason'), - error: pickAppText(locale, '错误', 'Error'), - origin_channel: pickAppText(locale, '来源 Channel', 'Origin channel'), - origin_chat_id: pickAppText(locale, '来源 Chat', 'Origin chat'), - }; -} - -function formatTraceValue(value: unknown): string | null { - if (value === null || value === undefined) return null; - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) { - const parts = value - .map((item) => formatTraceValue(item)) - .filter((item): item is string => Boolean(item)); - return parts.length > 0 ? parts.join(', ') : null; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function traceMetadataEntries( - metadata: Record<string, unknown> | null | undefined, - labels: Record<string, string> -): Array<{ key: string; label: string; value: string }> { - if (!metadata) return []; - - const entries: Array<{ key: string; label: string; value: string }> = []; - const used = new Set<string>(); - - for (const [key, label] of Object.entries(labels)) { - const value = formatTraceValue(metadata[key]); - if (!value) continue; - used.add(key); - entries.push({ key, label, value }); - } - - for (const [key, rawValue] of Object.entries(metadata)) { - if (used.has(key)) continue; - const value = formatTraceValue(rawValue); - if (!value) continue; - entries.push({ key, label: key, value }); - } - - return entries; -} - -function PixelPanel({ - title, - subtitle, - children, - icon: Icon, -}: { - title: string; - subtitle?: string; - children: React.ReactNode; - icon?: React.ComponentType<{ className?: string }>; -}) { - return ( - <div className="rounded-none border-4 border-[#0e1119] bg-[#141722] p-4 text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]"> - <div className="flex items-center gap-2 font-mono text-sm font-bold uppercase tracking-[0.18em] text-[#fef3c7]"> - {Icon ? <Icon className="h-4 w-4" /> : null} - {title} - </div> - {subtitle ? ( - <div className="mt-2 text-xs text-slate-400">{subtitle}</div> - ) : null} - <div className="mt-4">{children}</div> - </div> - ); -} - -function BoardPanel({ - icon: Icon, - title, - description, - children, -}: { - icon: React.ComponentType<{ className?: string }>; - title: string; - description?: string; - children: React.ReactNode; -}) { - return ( - <Card className="rounded-none border-4 border-[#0e1119] bg-[#141722] text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]"> - <CardHeader className="border-b border-[#262a3d] pb-3"> - <CardTitle className="flex items-center gap-2 text-base text-[#fef3c7]"> - <Icon className="h-4 w-4" /> - {title} - </CardTitle> - {description ? <CardDescription className="text-slate-400">{description}</CardDescription> : null} - </CardHeader> - <CardContent>{children}</CardContent> - </Card> - ); -} - -export default function OfficeDetailPage() { - const { locale } = useAppI18n(); - const params = useParams<{ taskId: string }>(); - const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? ''); - - const sessions = useChatStore((state) => state.sessions); - const processRuns = useChatStore((state) => state.processRuns); - const processEvents = useChatStore((state) => state.processEvents); - const processArtifacts = useChatStore((state) => state.processArtifacts); - - const office = React.useMemo( - () => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale), - [locale, processArtifacts, processEvents, processRuns, sessions, taskId] - ); - const metadataLabels = React.useMemo(() => traceMetadataLabels(locale), [locale]); - - const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null); - const [detailOpen, setDetailOpen] = React.useState(false); - - React.useEffect(() => { - setSelectedRunId(office?.rootRunId ?? null); - setDetailOpen(false); - }, [office?.rootRunId]); - - const selectedTask = React.useMemo( - () => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null, - [office?.tasks, selectedRunId] - ); - const selectedRun = React.useMemo( - () => processRuns.find((run) => run.run_id === selectedTask?.runId) ?? null, - [processRuns, selectedTask?.runId] - ); - const selectedRunMetadata = React.useMemo( - () => traceMetadataEntries(selectedRun?.metadata, metadataLabels), - [metadataLabels, selectedRun?.metadata] - ); - - const selectedEvents = React.useMemo( - () => processEvents - .filter((event) => event.run_id === selectedTask?.runId) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, 16), - [processEvents, selectedTask?.runId] - ); - - const selectedArtifacts = React.useMemo( - () => processArtifacts - .filter((artifact) => artifact.run_id === selectedTask?.runId) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()), - [processArtifacts, selectedTask?.runId] - ); - - const openRunDetail = React.useCallback((runId: string) => { - setSelectedRunId(runId); - setDetailOpen(true); - }, []); - - if (!office) { - return ( - <div className="mx-auto flex max-w-4xl flex-col gap-4 p-6"> - <Button asChild variant="outline" className="w-fit"> - <Link href="/office"> - <ArrowLeft className="mr-2 h-4 w-4" /> - {pickAppText(locale, '返回 Office 列表', 'Back to office list')} - </Link> - </Button> - <Card className="border-dashed"> - <CardContent className="py-16 text-center"> - <h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1> - <p className="mt-2 text-sm text-muted-foreground"> - {pickAppText( - locale, - '当前 store 中没有这个 task 的运行数据。先从对话页发起任务,或者回到 Office 列表查看当前可用任务。', - 'The current store does not contain runtime data for this task yet. Start it from chat first, or return to the office list to inspect available tasks.' - )} - </p> - </CardContent> - </Card> - </div> - ); - } - - const progressValue = progressPercent(office.progress.value, office.progress.max); - - return ( - <div className="mx-auto max-w-[1720px] space-y-6 p-6"> - <TaskManagementTabs /> - - <div className="flex flex-wrap items-center gap-3"> - <Button asChild variant="outline" size="sm"> - <Link href="/office"> - <ArrowLeft className="mr-2 h-4 w-4" /> - {pickAppText(locale, '返回 Office', 'Back to office')} - </Link> - </Button> - <Button asChild variant="ghost" size="sm"> - <Link href="/"> - <MessageSquare className="mr-2 h-4 w-4" /> - {pickAppText(locale, '回到对话', 'Back to chat')} - </Link> - </Button> - </div> - - <div className="space-y-5"> - <div className="mx-auto max-w-[1280px] rounded-none border-4 border-[#0e1119] bg-[#141522] p-4 shadow-[0_0_0_2px_#241d36_inset]"> - <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> - <div className="min-w-0"> - <div className="flex flex-wrap items-center gap-3"> - <h1 className="truncate font-mono text-3xl font-bold uppercase tracking-[0.18em] text-[#fef3c7]"> - {office.title} - </h1> - <OfficeStatusBadge status={office.status} className="bg-black/20" /> - </div> - <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400"> - <span>{pickAppText(locale, '负责人', 'Lead')}: {office.rootActorName}</span> - <span>{pickAppText(locale, '会话', 'Session')}: {office.sourceSessionLabel}</span> - <span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(office.createdAt, locale)}</span> - <span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(office.durationMs, locale)}</span> - </div> - </div> - - <div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]"> - <MetricTile label={pickAppText(locale, '运行实例', 'Runs')} value={String(office.stats.totalRuns)} /> - <MetricTile label={pickAppText(locale, '参与成员', 'Members')} value={String(office.stats.memberCount)} /> - <MetricTile label={pickAppText(locale, '产物数量', 'Artifacts')} value={String(office.stats.artifactCount)} /> - <MetricTile label={pickAppText(locale, '告警数量', 'Alerts')} value={String(office.alerts.length)} /> - </div> - </div> - </div> - - <div className="mx-auto max-w-[1280px]"> - <OfficePhaserCanvas - office={office} - selectedRunId={selectedTask?.runId ?? null} - onRunSelect={openRunDetail} - showMetaBar={false} - /> - </div> - - <div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]"> - <PixelPanel - title={pickAppText(locale, '昨日小记', 'Yesterday notes')} - subtitle={pickAppText(locale, '用任务摘要、告警和最近更新来替代原版 memo 区。', 'Use task summaries, alerts, and recent updates instead of the original memo area.')} - > - <div className="space-y-3 text-sm leading-6 text-slate-300"> - <div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3"> - {selectedTask?.summary || pickAppText(locale, '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。', 'The selected task has no summary yet. Pick a specific run from the board on the right to inspect the floor.')} - </div> - {office.alerts.slice(0, 2).map((alert) => ( - <button - key={alert.id} - type="button" - disabled={!alert.runId} - onClick={() => alert.runId && openRunDetail(alert.runId)} - className="block w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default" - > - <div className="font-medium text-rose-200">{alert.title}</div> - {alert.description ? <div className="mt-1 text-xs text-slate-400">{alert.description}</div> : null} - </button> - ))} - </div> - </PixelPanel> - - <PixelPanel - title={pickAppText(locale, '任务控制台', 'Task console')} - subtitle={pickAppText(locale, '保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。', 'Keep the original center console position, but back it with real task runtime data.')} - > - <div className="space-y-4"> - <div className="grid gap-3 sm:grid-cols-2"> - <MiniMetric label={pickAppText(locale, '当前阶段', 'Current stage')} value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} /> - <MiniMetric label={pickAppText(locale, '活跃实例', 'Active runs')} value={String(office.stats.activeRuns)} /> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between gap-3 font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400"> - <span>{office.progress.label}</span> - <span>{progressValue}%</span> - </div> - <div className="h-4 rounded-none border-2 border-[#263144] bg-[#0f1420] p-[2px]"> - <div - className="h-full bg-[linear-gradient(90deg,#22d3ee,#fde047,#fb7185)] transition-all" - style={{ width: `${progressValue}%` }} - /> - </div> - </div> - - {selectedTask ? ( - <div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3"> - <div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{pickAppText(locale, '当前聚焦', 'Current focus')}</div> - <div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div> - <div className="mt-1 text-xs text-slate-400"> - {selectedTask.actorName} · {selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')} - </div> - </div> - ) : null} - - <div className="grid gap-2 sm:grid-cols-2"> - <Button - onClick={() => setDetailOpen(true)} - className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]" - > - {pickAppText(locale, '打开详情', 'Open details')} - <PanelRightOpen className="ml-2 h-4 w-4" /> - </Button> - <Button - asChild - variant="outline" - className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]" - > - <Link href="/"> - {pickAppText(locale, '回到对话', 'Back to chat')} - <ArrowRight className="ml-2 h-4 w-4" /> - </Link> - </Button> - </div> - - {isOfficeTaskTerminal(office.status) ? ( - <div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200"> - {pickAppText(locale, '任务已结束,办公室已解散,但现场记录仍可回看。', 'The task has ended and the office has dissolved, but the floor record is still available for review.')} - </div> - ) : null} - </div> - </PixelPanel> - - <PixelPanel - title={pickAppText(locale, '办公人员名单', 'Roster')} - subtitle={pickAppText(locale, '原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。', 'Replacement for the original visitor area, showing the agents currently participating in this task.')} - > - <div className="space-y-2"> - {office.members.map((member) => ( - <button - key={member.memberId} - type="button" - onClick={() => openRunDetail(member.currentRunId)} - className="flex w-full items-center justify-between gap-3 rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left transition-colors hover:border-[#64748b]" - > - <div className="min-w-0"> - <div className="truncate font-medium text-slate-100">{member.actorName}</div> - <div className="truncate text-xs text-slate-400">{member.currentTitle}</div> - </div> - <OfficeStatusBadge status={member.status} /> - </button> - ))} - </div> - </PixelPanel> - </div> - - <div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]"> - <BoardPanel - icon={ListTree} - title={pickAppText(locale, '任务看板', 'Task board')} - description={pickAppText(locale, '当前 task 下所有 run 的结构化列表。', 'Structured list of all runs under this task.')} - > - <div className="space-y-3"> - {office.tasks.map((task) => ( - <button - key={task.runId} - type="button" - onClick={() => openRunDetail(task.runId)} - className={`w-full rounded-none border-2 px-4 py-3 text-left transition-colors ${ - selectedTask?.runId === task.runId - ? 'border-[#facc15] bg-[#201922]' - : 'border-[#2d3348] bg-[#0f1420] hover:border-[#64748b]' - }`} - > - <div className="flex items-start justify-between gap-3"> - <div className="min-w-0"> - <div className="flex items-center gap-2"> - <span className="truncate font-medium text-slate-100">{task.title}</span> - {task.isRoot ? ( - <span className="rounded-none border border-[#4a3c17] bg-[#3b2f12] px-2 py-0.5 font-mono text-[10px] text-[#fef3c7]"> - ROOT - </span> - ) : null} - </div> - <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400"> - <span>{task.actorName}</span> - <span>{formatOfficeTime(task.updatedAt, locale)}</span> - <span>{pickAppText(locale, `${task.artifactCount} 个产物`, `${task.artifactCount} artifacts`)}</span> - </div> - </div> - <OfficeStatusBadge status={task.status} /> - </div> - </button> - ))} - </div> - </BoardPanel> - - <div className="space-y-5"> - <BoardPanel - icon={Boxes} - title={pickAppText(locale, '分工关系', 'Assignments')} - description={pickAppText(locale, '主 Agent 到子 Agent 的委派关系。', 'Delegation links from the lead agent to sub-agents.')} - > - <div className="space-y-2"> - {office.assignments.length === 0 ? ( - <div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400"> - {pickAppText(locale, '当前没有可见的子任务分工。', 'No visible subtask assignments yet.')} - </div> - ) : ( - office.assignments.map((assignment) => ( - <button - key={assignment.ownerRunId} - type="button" - onClick={() => openRunDetail(assignment.ownerRunId)} - className="w-full rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left text-sm transition-colors hover:border-[#64748b]" - > - <div className="font-medium text-slate-100">{assignment.label}</div> - <div className="mt-1 text-slate-400">{assignment.assigneeActorNames.join(' / ')}</div> - </button> - )) - )} - </div> - </BoardPanel> - - <BoardPanel - icon={Siren} - title={pickAppText(locale, '现场告警', 'Live alerts')} - description={pickAppText(locale, '优先展示失败、阻塞和较高风险的任务信号。', 'Prioritize failed, blocked, and higher-risk task signals.')} - > - <div className="space-y-2"> - {office.alerts.length === 0 ? ( - <div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400"> - {pickAppText(locale, '当前没有高优先级告警。', 'There are no high-priority alerts right now.')} - </div> - ) : ( - office.alerts.map((alert) => ( - <button - key={alert.id} - type="button" - disabled={!alert.runId} - onClick={() => alert.runId && openRunDetail(alert.runId)} - className="w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default" - > - <div className="font-medium text-rose-200">{alert.title}</div> - {alert.description ? <div className="mt-1 text-sm text-slate-400">{alert.description}</div> : null} - </button> - )) - )} - </div> - </BoardPanel> - </div> - </div> - </div> - - <Sheet open={detailOpen} onOpenChange={setDetailOpen}> - <SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl"> - <SheetHeader className="pr-8"> - <SheetTitle>{selectedTask?.title ?? pickAppText(locale, '任务详情', 'Task details')}</SheetTitle> - <SheetDescription> - {selectedTask - ? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}` - : pickAppText(locale, '当前没有选中的任务实例。', 'No task run is currently selected.')} - </SheetDescription> - </SheetHeader> - - {!selectedTask ? ( - <div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground"> - {pickAppText(locale, '当前没有可展示的任务详情。', 'There are no task details to display right now.')} - </div> - ) : ( - <ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3"> - <div className="space-y-4 pb-6"> - <div className="rounded-xl border border-border/60 px-4 py-4"> - <div className="flex items-center justify-between gap-3"> - <div className="min-w-0"> - <div className="truncate font-medium">{selectedTask.title}</div> - <div className="mt-1 text-xs text-muted-foreground">{selectedTask.actorName}</div> - </div> - <OfficeStatusBadge status={selectedTask.status} /> - </div> - <div className="mt-3 grid gap-2 text-sm"> - <div className="flex items-center justify-between gap-3"> - <span className="text-muted-foreground">{pickAppText(locale, '开始时间', 'Started')}</span> - <span>{formatOfficeTime(selectedTask.startedAt, locale)}</span> - </div> - <div className="flex items-center justify-between gap-3"> - <span className="text-muted-foreground">{pickAppText(locale, '最近更新', 'Last update')}</span> - <span>{formatOfficeTime(selectedTask.updatedAt, locale)}</span> - </div> - <div className="flex items-center justify-between gap-3"> - <span className="text-muted-foreground">{pickAppText(locale, '阶段', 'Stage')}</span> - <span>{selectedTask.stageLabel ?? '-'}</span> - </div> - </div> - {selectedRunMetadata.length > 0 ? ( - <div className="mt-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3"> - <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '链路上下文', 'Trace context')}</div> - <div className="mt-2 space-y-1.5"> - {selectedRunMetadata.map((item) => ( - <div key={item.key} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]"> - <span className="text-muted-foreground">{item.label}</span> - <span className="break-words font-mono text-foreground/90">{item.value}</span> - </div> - ))} - </div> - </div> - ) : null} - {selectedTask.summary ? ( - <div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground"> - {selectedTask.summary} - </div> - ) : null} - </div> - - <div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]"> - <div className="rounded-xl border border-border/60"> - <div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '产物', 'Artifacts')}</div> - <div className="space-y-2 p-4"> - {selectedArtifacts.length === 0 ? ( - <div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有产物。', 'There are no artifacts for this task.')}</div> - ) : ( - selectedArtifacts.map((artifact) => ( - <div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3"> - <div className="font-medium">{artifact.title}</div> - <div className="mt-1 text-xs text-muted-foreground"> - {artifact.artifact_type} · {formatOfficeTime(artifact.created_at, locale)} - </div> - </div> - )) - )} - </div> - </div> - - <div className="rounded-xl border border-border/60"> - <div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '最近事件', 'Recent events')}</div> - <div className="space-y-2 p-4"> - {selectedEvents.length === 0 ? ( - <div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有事件。', 'There are no events for this task.')}</div> - ) : ( - selectedEvents.map((event) => { - const metadataEntries = traceMetadataEntries(event.metadata, metadataLabels); - return ( - <div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3"> - <div className="flex items-center justify-between gap-3"> - <div className="text-xs uppercase tracking-wide text-muted-foreground">{appEventKindLabel(event.kind, locale)}</div> - <div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at, locale)}</div> - </div> - {event.status ? ( - <div className="mt-2 text-xs text-muted-foreground">{pickAppText(locale, '状态', 'Status')}: {event.status}</div> - ) : null} - <div className="mt-2 text-sm text-foreground/90"> - {event.text || pickAppText(locale, '结构化更新', 'Structured update')} - </div> - {metadataEntries.length > 0 ? ( - <div className="mt-3 rounded-md bg-muted/20 px-3 py-2"> - <div className="mb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '事件上下文', 'Event context')}</div> - <div className="space-y-1.5"> - {metadataEntries.map((item) => ( - <div key={`${event.event_id}:${item.key}`} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]"> - <span className="text-muted-foreground">{item.label}</span> - <span className="break-words font-mono text-foreground/90">{item.value}</span> - </div> - ))} - </div> - </div> - ) : null} - </div> - )}) - )} - </div> - </div> - </div> - </div> - </ScrollArea> - )} - </SheetContent> - </Sheet> - </div> - ); -} - -function MetricTile({ label, value }: { label: string; value: string }) { - return ( - <div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-4 py-4 text-slate-100"> - <div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div> - <div className="mt-2 text-xl font-semibold">{value}</div> - </div> - ); -} - -function MiniMetric({ label, value }: { label: string; value: string }) { - return ( - <div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-slate-100"> - <div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div> - <div className="mt-2 text-sm font-semibold">{value}</div> - </div> - ); +export default function OfficeTaskRedirectPage({ params }: { params: { taskId: string } }) { + redirect(`/tasks/${params.taskId}`); } diff --git a/app-instance/frontend/app/(app)/office/page.tsx b/app-instance/frontend/app/(app)/office/page.tsx index 6113c6c..eb28339 100644 --- a/app-instance/frontend/app/(app)/office/page.tsx +++ b/app-instance/frontend/app/(app)/office/page.tsx @@ -1,284 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import Link from 'next/link'; -import React from 'react'; -import { - Activity, - ArrowRight, - Clock3, - FolderKanban, - Loader2, - Sparkles, - Users, -} from 'lucide-react'; - -import { OfficeStatusBadge, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared'; -import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office'; -import { appConnectionStatusLabel } from '@/lib/i18n/common'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; -import { useChatStore } from '@/lib/store'; - -function TaskCard({ - taskId, - title, - sessionLabel, - rootActorName, - status, - updatedAt, - memberCount, - activeRuns, - artifactCount, - errorCount, - currentStageLabel, - progressLabel, - progressValue, - locale, -}: { - taskId: string; - title: string; - sessionLabel: string; - rootActorName: string; - status: Parameters<typeof OfficeStatusBadge>[0]['status']; - updatedAt: string; - memberCount: number; - activeRuns: number; - artifactCount: number; - errorCount: number; - currentStageLabel: string | null; - progressLabel: string; - progressValue: number; - locale: 'zh-CN' | 'en-US'; -}) { - return ( - <Card className="border-border/80 transition-colors hover:border-primary/30"> - <CardHeader className="pb-3"> - <div className="flex items-start justify-between gap-3"> - <div className="min-w-0 flex-1"> - <CardTitle className="truncate text-lg">{title}</CardTitle> - <CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> - <span>{pickAppText(locale, '会话', 'Session')}: {sessionLabel}</span> - <span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {rootActorName}</span> - <span>{pickAppText(locale, '更新于', 'Updated')} {formatOfficeTime(updatedAt, locale)}</span> - </CardDescription> - </div> - <OfficeStatusBadge status={status} /> - </div> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid gap-3 sm:grid-cols-4"> - <Metric icon={Users} label={pickAppText(locale, '成员', 'Members')} value={String(memberCount)} /> - <Metric icon={Activity} label={pickAppText(locale, '活跃', 'Active')} value={String(activeRuns)} /> - <Metric icon={FolderKanban} label={pickAppText(locale, '产物', 'Artifacts')} value={String(artifactCount)} /> - <Metric icon={Sparkles} label={pickAppText(locale, '异常', 'Alerts')} value={String(errorCount)} /> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between gap-3 text-sm"> - <span className="truncate text-muted-foreground">{progressLabel}</span> - {currentStageLabel ? <span className="truncate font-medium">{currentStageLabel}</span> : null} - </div> - <div className="h-2.5 overflow-hidden rounded-full bg-secondary"> - <div - className="h-full rounded-full bg-primary transition-all" - style={{ width: `${progressValue}%` }} - /> - </div> - </div> - - <div className="flex justify-end"> - <Button asChild size="sm"> - <Link href={`/office/${encodeURIComponent(taskId)}`}> - {pickAppText(locale, '进入办公室', 'Open office')} - <ArrowRight className="ml-2 h-4 w-4" /> - </Link> - </Button> - </div> - </CardContent> - </Card> - ); -} - -function Metric({ - icon: Icon, - label, - value, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - value: string; -}) { - return ( - <div className="rounded-xl border border-border/60 bg-muted/30 px-3 py-3"> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <Icon className="h-3.5 w-3.5" /> - <span>{label}</span> - </div> - <div className="mt-2 text-lg font-semibold">{value}</div> - </div> - ); -} - -export default function OfficeListPage() { - const { locale } = useAppI18n(); - const sessionId = useChatStore((state) => state.sessionId); - const sessions = useChatStore((state) => state.sessions); - const processRuns = useChatStore((state) => state.processRuns); - const processEvents = useChatStore((state) => state.processEvents); - const processArtifacts = useChatStore((state) => state.processArtifacts); - const wsStatus = useChatStore((state) => state.wsStatus); - - const tasks = React.useMemo( - () => buildOfficeTaskList({ - sessionId, - sessions, - processRuns, - processEvents, - processArtifacts, - }, locale), - [locale, processArtifacts, processEvents, processRuns, sessionId, sessions] - ); - - const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status)); - const recentTasks = tasks.filter((task) => isOfficeTaskTerminal(task.status)); - - return ( - <div className="mx-auto max-w-7xl space-y-6 p-6"> - <TaskManagementTabs /> - - <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> - <div> - <h1 className="text-3xl font-semibold tracking-tight">Office</h1> - <p className="mt-2 max-w-3xl text-sm text-muted-foreground"> - {pickAppText( - locale, - '基于当前会话的真实运行数据,展示主 Agent 与子 Agent 的任务现场。任务结束后会从活跃现场移除,但保留回看入口。', - 'Show the live task floor for the lead agent and its sub-agents using real runtime data from the current session. Finished tasks leave the active floor but remain available for review.' - )} - </p> - </div> - <Card className="min-w-[280px] border-border/70"> - <CardContent className="flex items-center justify-between gap-4 p-4"> - <div> - <div className="text-xs text-muted-foreground">{pickAppText(locale, '当前会话', 'Current session')}</div> - <div className="mt-1 font-medium">{sessionId}</div> - </div> - <div className="text-right"> - <div className="text-xs text-muted-foreground">{pickAppText(locale, '连接状态', 'Connection')}</div> - <div className="mt-1 font-medium">{appConnectionStatusLabel(wsStatus, wsStatus === 'connected' ? true : null, locale)}</div> - </div> - </CardContent> - </Card> - </div> - - {wsStatus === 'connecting' && tasks.length === 0 ? ( - <div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"> - <Loader2 className="h-4 w-4 animate-spin" /> - {pickAppText(locale, '正在等待运行时数据...', 'Waiting for runtime data...')} - </div> - ) : null} - - {tasks.length === 0 ? ( - <Card className="border-dashed"> - <CardContent className="flex flex-col items-center justify-center py-16 text-center"> - <Clock3 className="h-10 w-10 text-muted-foreground/50" /> - <h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '当前没有可展示的任务现场', 'No task floor is available yet')}</h2> - <p className="mt-2 max-w-xl text-sm text-muted-foreground"> - {pickAppText( - locale, - '先回到对话页发起一次主 Agent 任务。开始执行后,这里会出现活跃的 office 卡片。', - 'Start a lead-agent task from the chat page first. Once it begins running, active office cards will appear here.' - )} - </p> - <Button asChild className="mt-6"> - <Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link> - </Button> - </CardContent> - </Card> - ) : ( - <> - <section className="space-y-4"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-xl font-semibold">{pickAppText(locale, '活跃 Office', 'Active office')}</h2> - <p className="text-sm text-muted-foreground">{pickAppText(locale, '正在运行中的任务现场会优先显示。', 'Running task floors are shown first.')}</p> - </div> - <div className="text-sm text-muted-foreground">{pickAppText(locale, `${activeTasks.length} 个任务`, `${activeTasks.length} tasks`)}</div> - </div> - {activeTasks.length === 0 ? ( - <Card className="border-dashed"> - <CardContent className="py-10 text-center text-sm text-muted-foreground"> - {pickAppText(locale, '当前没有活跃任务,下面可以查看最近结束的任务。', 'There are no active tasks right now. Recent finished tasks are listed below.')} - </CardContent> - </Card> - ) : ( - <div className="grid gap-4 lg:grid-cols-2"> - {activeTasks.map((task) => ( - <TaskCard - key={task.taskId} - taskId={task.taskId} - title={task.title} - sessionLabel={task.sessionLabel} - rootActorName={task.rootActorName} - status={task.status} - updatedAt={task.updatedAt} - memberCount={task.memberCount} - activeRuns={task.activeRuns} - artifactCount={task.artifactCount} - errorCount={task.errorCount} - currentStageLabel={task.currentStageLabel} - progressLabel={task.progress.label} - progressValue={progressPercent(task.progress.value, task.progress.max)} - locale={locale} - /> - ))} - </div> - )} - </section> - - <section className="space-y-4"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-xl font-semibold">{pickAppText(locale, '最近结束', 'Recently finished')}</h2> - <p className="text-sm text-muted-foreground">{pickAppText(locale, '已完成、失败或取消的任务仍保留回看入口。', 'Completed, failed, or cancelled tasks remain available for review.')}</p> - </div> - <div className="text-sm text-muted-foreground">{pickAppText(locale, `${recentTasks.length} 个任务`, `${recentTasks.length} tasks`)}</div> - </div> - {recentTasks.length === 0 ? ( - <Card className="border-dashed"> - <CardContent className="py-10 text-center text-sm text-muted-foreground"> - {pickAppText(locale, '还没有历史任务。', 'There is no task history yet.')} - </CardContent> - </Card> - ) : ( - <div className="grid gap-4 lg:grid-cols-2"> - {recentTasks.map((task) => ( - <TaskCard - key={task.taskId} - taskId={task.taskId} - title={task.title} - sessionLabel={task.sessionLabel} - rootActorName={task.rootActorName} - status={task.status} - updatedAt={task.updatedAt} - memberCount={task.memberCount} - activeRuns={task.activeRuns} - artifactCount={task.artifactCount} - errorCount={task.errorCount} - currentStageLabel={task.currentStageLabel} - progressLabel={task.progress.label} - progressValue={progressPercent(task.progress.value, task.progress.max)} - locale={locale} - /> - ))} - </div> - )} - </section> - </> - )} - </div> - ); +export default function OfficeRedirectPage() { + redirect('/tasks'); } diff --git a/app-instance/frontend/app/(app)/page.tsx b/app-instance/frontend/app/(app)/page.tsx index c60e1f3..744028c 100644 --- a/app-instance/frontend/app/(app)/page.tsx +++ b/app-instance/frontend/app/(app)/page.tsx @@ -2,31 +2,27 @@ import Link from 'next/link'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react'; +import { Brain, Plus, Send, Trash2, X } from 'lucide-react'; -import { OfficeStatusBadge } from '@/components/office/OfficeShared'; import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench'; -import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; import { cancelDelegation, + archiveSession, createSession, - deleteSession, + getActiveTask, getSession, getSessionProcess, - listCommands, listSessions, sendMessage, submitChatFeedback, uploadFile, wsManager, } from '@/lib/api'; -import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; import { useChatStore } from '@/lib/store'; -import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types'; +import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types'; function messageFingerprint(msg: ChatMessage): string { const attachmentKey = (msg.attachments ?? []) @@ -62,6 +58,23 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is return data.type === 'session_updated' && typeof data.session_id === 'string'; } +function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') { + if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision'); + if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback'); + if (status === 'running') return pickAppText(locale, '进行中', 'Running'); + return pickAppText(locale, '进行中', 'Active'); +} + +const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled'; + +function loadThinkingModePreference(): boolean { + if (typeof window === 'undefined') { + return true; + } + const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY); + return stored == null ? true : stored !== 'false'; +} + export default function ChatPage() { const { locale } = useAppI18n(); const { @@ -86,30 +99,19 @@ export default function ChatPage() { } = useChatStore(); const [input, setInput] = useState(''); - const [commands, setCommands] = useState<SlashCommand[]>([]); - const [showCommandPicker, setShowCommandPicker] = useState(false); - const [pickerIndex, setPickerIndex] = useState(0); + const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference); const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]); + const [activeTask, setActiveTask] = useState<ActiveTask | null>(null); const messagesEndRef = useRef<HTMLDivElement>(null); const messageViewportRef = useRef<HTMLDivElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null); - const pickerRef = useRef<HTMLDivElement>(null); const fileInputRef = useRef<HTMLInputElement>(null); const loadSessionReqSeq = useRef(0); - const commandsLoadedRef = useRef(false); const refreshSessionOnReconnectRef = useRef(false); const hasConnectedRef = useRef(false); const shouldSnapToLatestRef = useRef(true); const wsStatus = useChatStore((state) => state.wsStatus); - const filteredCommands = useMemo(() => { - if (!input.startsWith('/') || input.includes(' ')) return []; - const filter = input.slice(1).toLowerCase(); - return commands.filter( - (command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter)) - ); - }, [commands, input]); - const sessionProcessRuns = useMemo( () => processRuns.filter((run) => run.session_id === sessionId), [processRuns, sessionId] @@ -132,19 +134,6 @@ export default function ChatPage() { const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null; - const officeTasks = useMemo( - () => buildOfficeTaskList({ - sessionId, - sessions, - processRuns, - processEvents, - processArtifacts, - }, locale), - [locale, processArtifacts, processEvents, processRuns, sessionId, sessions] - ); - - const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null; - const loadSessions = useCallback(async () => { try { const list = await listSessions(); @@ -154,6 +143,17 @@ export default function ChatPage() { } }, []); + const loadActiveTask = useCallback(async (key: string) => { + try { + if (useChatStore.getState().sessionId !== key) return; + setActiveTask(await getActiveTask(key)); + } catch { + if (useChatStore.getState().sessionId === key) { + setActiveTask(null); + } + } + }, []); + const loadSessionMessages = useCallback(async (key: string) => { const reqSeq = ++loadSessionReqSeq.current; const localSnapshot = useChatStore.getState().messages; @@ -168,6 +168,7 @@ export default function ChatPage() { if (process) { setSessionProcess(key, process); } + void loadActiveTask(key); const nextMessages = waitingForReply ? mergeServerWithPendingUsers(detail.messages, localSnapshot) : detail.messages; @@ -182,36 +183,16 @@ export default function ChatPage() { if (reqSeq !== loadSessionReqSeq.current) return; if (useChatStore.getState().sessionId !== key) return; } - }, [setIsLoading, setIsThinking, setMessages, setSessionProcess]); - - const loadCommands = useCallback(async () => { - if (commandsLoadedRef.current) return; - commandsLoadedRef.current = true; - try { - const nextCommands = await listCommands(); - setCommands(nextCommands); - } catch { - commandsLoadedRef.current = false; - } - }, []); - - useEffect(() => { - if (input.startsWith('/') && !input.includes(' ')) { - void loadCommands(); - } - }, [input, loadCommands]); - - useEffect(() => { - setShowCommandPicker(filteredCommands.length > 0); - setPickerIndex(0); - }, [filteredCommands]); + }, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]); useEffect(() => { clearMessages(); setIsLoading(false); setIsThinking(false); + setActiveTask(null); void loadSessionMessages(sessionId); - }, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]); + void loadActiveTask(sessionId); + }, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]); useEffect(() => { if (wsStatus === 'connected') { @@ -260,6 +241,7 @@ export default function ChatPage() { validation_status: validationStatus, }); void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId); + void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId); loadSessions(); } }); @@ -267,7 +249,7 @@ export default function ChatPage() { return () => { unsubMessage(); }; - }, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); + }, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); useEffect(() => { if (!isLoading && !isThinking) { @@ -311,18 +293,6 @@ export default function ChatPage() { shouldSnapToLatestRef.current = false; }, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]); - useEffect(() => { - if (!showCommandPicker || !pickerRef.current) return; - const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined; - item?.scrollIntoView({ block: 'nearest' }); - }, [pickerIndex, showCommandPicker]); - - const selectCommand = useCallback((command: SlashCommand) => { - setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`); - setShowCommandPicker(false); - textareaRef.current?.focus(); - }, []); - const handleSend = useCallback(async () => { const text = input.trim(); if ((!text && pendingFiles.length === 0) || isLoading) return; @@ -337,7 +307,6 @@ export default function ChatPage() { setInput(''); setPendingFiles([]); - setShowCommandPicker(false); const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)'); addMessage({ @@ -350,14 +319,20 @@ export default function ChatPage() { setIsThinking(false); if (wsManager.getStatus() === 'connected') { - const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent }; + const wsPayload: Record<string, unknown> = { + type: 'message', + content: msgContent, + thinking_enabled: thinkingModeEnabled, + }; if (attachments.length > 0) { wsPayload.attachments = attachments; } wsManager.sendRaw(wsPayload); } else { try { - const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined); + const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined, { + thinkingEnabled: thinkingModeEnabled, + }); setIsThinking(false); setIsLoading(false); if (result.response) { @@ -377,9 +352,11 @@ export default function ChatPage() { : 'unknown', }); void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null); + void loadActiveTask(sessionId); loadSessions(); } else { await loadSessionMessages(sessionId); + void loadActiveTask(sessionId); loadSessions(); } } catch { @@ -395,48 +372,27 @@ export default function ChatPage() { }); } } - }, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess]); + }, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled]); - const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => { + const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => { updateMessageFeedback(runId, feedbackType); try { await submitChatFeedback({ sessionId, runId, feedbackType, + comment, }); void loadSessionMessages(sessionId); void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null); + void loadActiveTask(sessionId); void loadSessions(); } catch (err: any) { updateMessageFeedback(runId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed')); } - }, [loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]); + }, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]); const handleKeyDown = (e: React.KeyboardEvent) => { - if (showCommandPicker && filteredCommands.length > 0) { - if (e.key === 'ArrowUp') { - e.preventDefault(); - setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1)); - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1)); - return; - } - if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) { - e.preventDefault(); - selectCommand(filteredCommands[pickerIndex]); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setShowCommandPicker(false); - return; - } - } - if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); @@ -470,6 +426,7 @@ export default function ChatPage() { const id = `web:${Date.now()}`; setSessionId(id); setSelectedRunId(null); + setActiveTask(null); clearMessages(); useChatStore.getState().resetProcessState(); try { @@ -480,23 +437,30 @@ export default function ChatPage() { void loadSessions(); }; - const handleDeleteSession = async (key: string, e: React.MouseEvent) => { + const handleArchiveSession = async (key: string, e: React.MouseEvent) => { e.stopPropagation(); try { - await deleteSession(key); + await archiveSession(key); + useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key)); if (key === sessionId) { setSessionId('web:default'); + setActiveTask(null); clearMessages(); useChatStore.getState().resetProcessState(); } - loadSessions(); + void loadSessions(); } catch { - // ignore transient errors + addMessage({ + role: 'assistant', + content: pickAppText(locale, '归档会话失败,请稍后重试。', 'Failed to archive the session. Please try again later.'), + timestamp: new Date().toISOString(), + }); } }; const handleSelectSession = (key: string) => { setSelectedRunId(null); + setActiveTask(null); setSessionId(key); }; @@ -516,6 +480,16 @@ export default function ChatPage() { setPendingFiles((prev) => prev.filter((item) => item.file !== file)); }, []); + const toggleThinkingMode = useCallback(() => { + setThinkingModeEnabled((current) => { + const next = !current; + if (typeof window !== 'undefined') { + window.localStorage.setItem(THINKING_MODE_STORAGE_KEY, String(next)); + } + return next; + }); + }, []); + const formatSessionName = (key: string) => { if (key.startsWith('web:')) { const id = key.slice(4); @@ -535,37 +509,42 @@ export default function ChatPage() { }; return ( - <div className="flex h-[calc(100vh-3.5rem)] bg-background"> - <div className="w-64 border-r border-border flex flex-col bg-card"> - <div className="p-3"> - <Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm"> - <Plus className="w-4 h-4" /> + <div className="flex h-[calc(100vh-4rem)] bg-background"> + <aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]"> + <div className="px-5 pb-5 pt-6"> + <button + type="button" + onClick={handleNewSession} + className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]" + > + <Plus className="h-4 w-4" /> {pickAppText(locale, '新对话', 'New chat')} - </Button> + </button> </div> - <Separator /> <ScrollArea className="flex-1"> - <div className="p-2 space-y-1"> + <div className="space-y-3 px-3 pb-6"> + <div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div> {sessions.length === 0 && ( - <p className="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p> + <p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p> )} {sessions.map((session) => ( <div key={session.key} onClick={() => handleSelectSession(session.key)} - className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${ + className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${ session.key === sessionId - ? 'bg-accent text-accent-foreground' - : 'text-muted-foreground hover:bg-accent/50' + ? 'bg-[#EFEEED] text-foreground' + : 'text-foreground hover:bg-[#EFEEED]/70' }`} > - <div className="flex items-center gap-2 truncate"> - <MessageSquare className="w-3.5 h-3.5 flex-shrink-0" /> + <div className="truncate"> <span className="truncate">{formatSessionName(session.key)}</span> </div> - <button - onClick={(event) => handleDeleteSession(session.key, event)} + <button + onClick={(event) => handleArchiveSession(session.key, event)} className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity" + title={pickAppText(locale, '归档会话', 'Archive session')} + aria-label={pickAppText(locale, '归档会话', 'Archive session')} > <Trash2 className="w-3.5 h-3.5" /> </button> @@ -573,40 +552,9 @@ export default function ChatPage() { ))} </div> </ScrollArea> - </div> + </aside> <div className="flex-1 flex flex-col min-w-0"> - {currentOfficeTask ? ( - <div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur"> - <div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> - <div className="min-w-0"> - <div className="flex flex-wrap items-center gap-2"> - <div className="flex items-center gap-2 text-sm font-medium"> - <Building2 className="h-4 w-4" /> - {pickAppText(locale, '当前任务现场', 'Current task floor')} - </div> - <OfficeStatusBadge status={currentOfficeTask.status} /> - </div> - <div className="mt-1 truncate text-sm text-muted-foreground"> - {currentOfficeTask.title} - <span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span> - </div> - </div> - <div className="flex shrink-0 items-center gap-2"> - <Button asChild variant="outline" size="sm"> - <Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link> - </Button> - <Button asChild size="sm"> - <Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}> - {pickAppText(locale, '查看任务现场', 'Open task floor')} - <ArrowRight className="ml-2 h-4 w-4" /> - </Link> - </Button> - </div> - </div> - </div> - ) : null} - <div className="flex-1 min-h-0"> <ChatWorkbench messages={messages} @@ -623,8 +571,23 @@ export default function ChatPage() { /> </div> - <div className="border-t border-border p-4 bg-background/95 backdrop-blur"> - <div className="max-w-5xl mx-auto"> + <div className="bg-background px-8 pb-8 pt-4"> + <div className="mx-auto max-w-5xl"> + {activeTask && ( + <div className="mb-2 flex"> + <Link + href={`/tasks/${encodeURIComponent(activeTask.task_id)}`} + className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]" + title={activeTask.description} + > + <span className="shrink-0 text-muted-foreground">{pickAppText(locale, '当前任务', 'Current task')}:</span> + <span className="truncate font-medium">{activeTask.short_title}</span> + <span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground"> + {activeTaskStatusLabel(activeTask.status, locale)} + </span> + </Link> + </div> + )} {pendingFiles.length > 0 && ( <div className="mb-2 space-y-1"> {pendingFiles.map((item, index) => ( @@ -640,7 +603,7 @@ export default function ChatPage() { <div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} /> </div> ) : ( - <span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span> + <span className="text-[#657162] text-xs">{pickAppText(locale, '就绪', 'Ready')}</span> )} <button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground"> <X className="w-3.5 h-3.5" /> @@ -650,62 +613,18 @@ export default function ChatPage() { </div> )} - <div className="relative flex gap-2"> - {showCommandPicker && filteredCommands.length > 0 && ( - <div - ref={pickerRef} - className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50" - > - {filteredCommands.map((command, index) => ( - <button - key={command.name} - className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${ - index === pickerIndex - ? 'bg-accent text-accent-foreground' - : 'hover:bg-accent/50 text-foreground' - }`} - onMouseDown={(event) => { - event.preventDefault(); - selectCommand(command); - }} - onMouseEnter={() => setPickerIndex(index)} - > - <span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span> - {command.argument_hint && ( - <span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span> - )} - <span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span> - {command.plugin_name !== 'builtin' && ( - <span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}> - {command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name} - </span> - )} - </button> - ))} - </div> - )} - + <div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]"> <input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} /> - <Button - onClick={() => fileInputRef.current?.click()} - variant="ghost" - size="icon" - className="h-10 w-10 flex-shrink-0" - title={pickAppText(locale, '添加附件', 'Add attachment')} - > - <Paperclip className="w-4 h-4" /> - </Button> - <textarea ref={textareaRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder={pickAppText(locale, '输入消息或 / 呼出命令…(回车发送,Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')} + placeholder={pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')} rows={1} - className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" - style={{ minHeight: '40px', maxHeight: '200px' }} + className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + style={{ minHeight: '72px', maxHeight: '200px' }} onInput={(e) => { const target = e.target as HTMLTextAreaElement; target.style.height = 'auto'; @@ -713,14 +632,44 @@ export default function ChatPage() { }} /> - <Button - onClick={handleSend} - disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading} - size="icon" - className="h-10 w-10 flex-shrink-0" - > - <Send className="w-4 h-4" /> - </Button> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-5 text-[15px] text-muted-foreground"> + <button + onClick={() => fileInputRef.current?.click()} + className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground" + title={pickAppText(locale, '添加附件', 'Add attachment')} + > + <Plus className="h-5 w-5" /> + </button> + <button + type="button" + onClick={toggleThinkingMode} + className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${ + thinkingModeEnabled + ? 'border-primary/40 bg-[#F1EFEE] text-foreground' + : 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground' + }`} + title={ + thinkingModeEnabled + ? pickAppText(locale, '思考模式已开启', 'Thinking mode is on') + : pickAppText(locale, '思考模式已关闭', 'Thinking mode is off') + } + aria-pressed={thinkingModeEnabled} + > + <Brain className="h-4 w-4" /> + {pickAppText(locale, '思考', 'Think')} + </button> + </div> + <button + type="button" + onClick={handleSend} + disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading} + className="flex h-12 w-12 items-center justify-center rounded-full bg-[#85817E] text-white transition-colors hover:bg-primary disabled:opacity-40" + aria-label={pickAppText(locale, '发送', 'Send')} + > + <Send className="h-5 w-5" /> + </button> + </div> </div> </div> </div> diff --git a/app-instance/frontend/app/(app)/settings/page.tsx b/app-instance/frontend/app/(app)/settings/page.tsx new file mode 100644 index 0000000..b0845c2 --- /dev/null +++ b/app-instance/frontend/app/(app)/settings/page.tsx @@ -0,0 +1 @@ +export { default } from '../status/page'; diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index 0656fb5..2c839e5 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -14,7 +14,6 @@ import { ShieldCheck, Trash2, Upload, - Wand2, X, XCircle, } from 'lucide-react'; @@ -27,11 +26,11 @@ import { listSkillCandidates, listSkillDrafts, listSkills, + migrateSkills, publishSkillDraft, regenerateSkillDraft, rejectSkillDraft, rollbackPublishedSkill, - runSkillLearningOnce, submitSkillDraft, synthesizeSkillDraft, uploadSkill, @@ -52,6 +51,9 @@ import type { Skill, SkillDraft, SkillLearningCandidate } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']); +const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']); + export default function SkillsPage() { const { locale } = useAppI18n(); const t = (zh: string, en: string) => pickAppText(locale, zh, en); @@ -62,7 +64,7 @@ export default function SkillsPage() { const [actionId, setActionId] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const [showUpload, setShowUpload] = useState(false); - const [deleting, setDeleting] = useState<string | null>(null); + const [ignoredCandidates, setIgnoredCandidates] = useState<Set<string>>(new Set()); const load = useCallback(async () => { setLoading(true); @@ -100,12 +102,11 @@ export default function SkillsPage() { } }; - const confirmDelete = async (name: string) => { - await runAction(`delete:${name}`, async () => { - await deleteSkill(name); - setDeleting(null); - }); - }; + const hiddenCandidateStatuses = new Set(['rejected', 'superseded', 'published']); + const visibleCandidates = candidates.filter( + (candidate) => !ignoredCandidates.has(candidate.candidate_id) && !hiddenCandidateStatuses.has(candidate.status) + ); + const visibleDrafts = drafts.filter((draft) => !TERMINAL_DRAFT_STATUSES.has(draft.status)); if (loading) { return ( @@ -127,22 +128,18 @@ export default function SkillsPage() { <RefreshCw className="mr-2 h-4 w-4" /> {t('刷新', 'Refresh')} </Button> + <Button onClick={() => setShowUpload(true)} size="sm"> + <Upload className="mr-2 h-4 w-4" /> + {t('上传技能', 'Upload skill')} + </Button> <Button - onClick={() => void runAction('learning:run-once', () => runSkillLearningOnce())} + onClick={() => void runAction('migrate-skills', () => migrateSkills())} variant="outline" size="sm" disabled={Boolean(actionId)} > - {actionId === 'learning:run-once' ? ( - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - ) : ( - <Wand2 className="mr-2 h-4 w-4" /> - )} - {t('运行学习', 'Run learning')} - </Button> - <Button onClick={() => setShowUpload(true)} size="sm"> - <Upload className="mr-2 h-4 w-4" /> - {t('上传技能', 'Upload skill')} + {actionId === 'migrate-skills' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />} + {t('迁移旧技能', 'Migrate legacy skills')} </Button> </div> </div> @@ -169,36 +166,18 @@ export default function SkillsPage() { /> )} - {deleting && ( - <Card className="border-destructive"> - <CardContent className="flex items-center justify-between gap-4 pt-6"> - <p className="text-sm"> - {t('确定删除技能', 'Delete skill')} <strong>{deleting}</strong>? - </p> - <div className="flex items-center gap-2"> - <Button variant="outline" size="sm" onClick={() => setDeleting(null)}> - {t('取消', 'Cancel')} - </Button> - <Button variant="destructive" size="sm" onClick={() => void confirmDelete(deleting)}> - {t('删除', 'Delete')} - </Button> - </div> - </CardContent> - </Card> - )} - <Tabs defaultValue="published" className="space-y-4"> <TabsList> <TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger> <TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger> - <TabsTrigger value="drafts">{t('草稿/评审', 'Drafts')}</TabsTrigger> + <TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger> </TabsList> <TabsContent value="published"> <PublishedSkillsTable skills={skills} onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))} - onDelete={(name) => setDeleting(name)} + onDelete={(name) => void runAction(`delete:${name}`, () => deleteSkill(name))} onDisable={(name) => runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable'))) } @@ -219,11 +198,11 @@ export default function SkillsPage() { <CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle> </CardHeader> <CardContent> - {candidates.length === 0 ? ( - <EmptyState icon={<Wand2 className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} /> + {visibleCandidates.length === 0 ? ( + <EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} /> ) : ( <div className="space-y-3"> - {candidates.map((candidate) => ( + {visibleCandidates.map((candidate) => ( <div key={candidate.candidate_id} className="rounded-lg border border-border p-4"> <div className="flex flex-wrap items-start justify-between gap-3"> <div className="min-w-0"> @@ -252,6 +231,14 @@ export default function SkillsPage() { )} </div> <div className="flex flex-wrap gap-2"> + <Button + size="sm" + variant="outline" + disabled={Boolean(actionId)} + onClick={() => setIgnoredCandidates((prev) => new Set(prev).add(candidate.candidate_id))} + > + {t('忽略', 'Ignore')} + </Button> <Button size="sm" disabled={Boolean(actionId)} @@ -299,11 +286,11 @@ export default function SkillsPage() { <CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle> </CardHeader> <CardContent> - {drafts.length === 0 ? ( + {visibleDrafts.length === 0 ? ( <EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无草稿', 'No drafts yet')} /> ) : ( <div className="space-y-4"> - {drafts.map((draft) => ( + {visibleDrafts.map((draft) => ( <DraftCard key={`${draft.skill_name}:${draft.draft_id}`} draft={draft} @@ -323,15 +310,11 @@ export default function SkillsPage() { rejectSkillDraft(draft.skill_name, draft.draft_id) ) } - onPublish={() => - runAction(`publish:${draft.draft_id}`, async () => { - const confirmHighRisk = draft.safety_report?.risk_level === 'high'; - if (confirmHighRisk && !window.confirm(t('这是高风险草稿,确认发布?', 'This is a high-risk draft. Publish anyway?'))) { - return; - } - await publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk); - }) - } + onPublish={(confirmHighRisk) => + runAction(`publish:${draft.draft_id}`, () => + publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk) + ) + } /> ))} </div> @@ -442,7 +425,7 @@ function DraftCard({ onSubmit: () => Promise<unknown>; onApprove: () => Promise<unknown>; onReject: () => Promise<unknown>; - onPublish: () => Promise<unknown>; + onPublish: (confirmHighRisk: boolean) => Promise<unknown>; }) { const { locale } = useAppI18n(); const t = (zh: string, en: string) => pickAppText(locale, zh, en); @@ -452,9 +435,27 @@ function DraftCard({ const publishBlocked = draft.status !== 'approved' || !safety - || !safety.passed || safety.risk_level === 'critical' || (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false); + const isHighRisk = safety?.risk_level === 'high'; + const highRiskReason = [ + ...(safety?.blocked_reasons ?? []), + ...(safety?.issues ?? []), + safety?.suggested_fix, + ].filter(Boolean).join('\n'); + const safetyBlocksReview = Boolean(safety && (!safety.passed || safety.risk_level === 'critical')); + const submitBlocked = draft.status !== 'draft' || safetyBlocksReview; + const approveBlocked = draft.status !== 'in_review' || safetyBlocksReview; + const rejectBlocked = !REJECTABLE_DRAFT_STATUSES.has(draft.status); + const handlePublish = () => { + if (isHighRisk) { + const confirmed = window.confirm( + t('该草稿被标记为高风险。确认发布?', 'This draft is marked high risk. Publish anyway?') + ); + if (!confirmed) return; + } + void onPublish(isHighRisk); + }; return ( <div className="rounded-lg border border-border p-4"> <div className="flex flex-wrap items-start justify-between gap-3"> @@ -478,33 +479,47 @@ function DraftCard({ <p className="mt-1 text-xs text-muted-foreground"> {t('base', 'base')}: {draft.base_version || '-'} </p> + {isHighRisk && ( + <div className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"> + <div className="font-medium">{t('高风险理由来自 safety report', 'High-risk reason from safety report')}</div> + <pre className="mt-2 whitespace-pre-wrap font-sans">{highRiskReason || t('未提供具体理由', 'No concrete reason provided')}</pre> + </div> + )} </div> <div className="flex flex-wrap gap-2"> - <Button variant="outline" size="sm" disabled={busy || draft.status !== 'draft'} onClick={() => void onSubmit()}> + <Button variant="outline" size="sm" disabled={busy || submitBlocked} onClick={() => void onSubmit()}> <Send className="mr-2 h-4 w-4" /> {t('送审', 'Submit')} </Button> - <Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onApprove()}> + <Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}> <Check className="mr-2 h-4 w-4" /> {t('批准', 'Approve')} </Button> - <Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onReject()}> + <Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}> <XCircle className="mr-2 h-4 w-4" /> {t('拒绝', 'Reject')} </Button> - <Button size="sm" disabled={busy || publishBlocked} onClick={() => void onPublish()}> - <Rocket className="mr-2 h-4 w-4" /> - {t('发布', 'Publish')} - </Button> + <Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}> + <Rocket className="mr-2 h-4 w-4" /> + {t('发布', 'Publish')} + </Button> </div> </div> <div className="mt-3 grid gap-3 md:grid-cols-2"> - <pre className="max-h-52 overflow-auto rounded-md bg-muted/50 p-3 text-xs"> - {JSON.stringify(draft.proposed_frontmatter, null, 2)} - </pre> - <pre className="max-h-52 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs"> - {draft.proposed_content} - </pre> + <div className="rounded-md border border-border bg-muted/30 p-3"> + <div className="mb-2 text-xs font-medium text-muted-foreground">{t('当前版本', 'Current version')}</div> + <pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs"> + {draft.base_version ? `${t('基线版本', 'Base version')}: ${draft.base_version}` : t('无基线版本,视为新增技能', 'No base version, treated as a new skill')} + </pre> + </div> + <div className="rounded-md border border-border bg-muted/30 p-3"> + <div className="mb-2 text-xs font-medium text-muted-foreground">{t('草稿变更', 'Draft changes')}</div> + <pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs"> + {JSON.stringify(draft.proposed_frontmatter, null, 2)} + {'\n\n---\n\n'} + {draft.proposed_content} + </pre> + </div> </div> <div className="mt-3 grid gap-3 md:grid-cols-2"> <ReportBlock @@ -595,6 +610,9 @@ function UploadSkillForm({ accept=".zip" className="block w-full cursor-pointer text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary-foreground hover:file:bg-primary/90" /> + <p className="text-xs text-muted-foreground"> + {pickAppText(locale, '上传后进入草稿评审,并自动运行 safety 和 eval。', 'After upload, the skill enters draft review and runs safety and eval automatically.')} + </p> </div> <div className="flex justify-end gap-2"> <Button type="button" variant="outline" onClick={onCancel}> diff --git a/app-instance/frontend/app/(app)/status/page.tsx b/app-instance/frontend/app/(app)/status/page.tsx index deaf46c..c924567 100644 --- a/app-instance/frontend/app/(app)/status/page.tsx +++ b/app-instance/frontend/app/(app)/status/page.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; import { CheckCircle2, XCircle, @@ -12,6 +13,7 @@ import { Key, Loader2, Settings2, + ScrollText, } from 'lucide-react'; import { getStatus, restartSystem, updateProviderConfig } from '@/lib/api'; import { @@ -189,65 +191,116 @@ export default function StatusPage() { if (!status) return null; + const settingsLinks = [ + { + href: '/logs', + icon: ScrollText, + title: pickAppText(locale, '运行日志', 'Runtime Logs'), + description: pickAppText(locale, '查看每次对话和后台任务的运行日志。', 'Inspect chat and background runtime logs.'), + }, + ]; + return ( - <div className="max-w-4xl mx-auto p-6 space-y-6"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-bold">{pickAppText(locale, '系统状态', 'System status')}</h1> + <div className="mx-auto max-w-6xl p-6 space-y-6"> + <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> + <div> + <h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1> + <p className="mt-1 text-sm text-muted-foreground"> + {pickAppText( + locale, + '集中管理模型、工具、集成和实例运行状态。Task 和通知只在各自页面处理。', + 'Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.' + )} + </p> + </div> <Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}> <RefreshCw className="w-4 h-4 mr-2" /> {pickAppText(locale, '刷新', 'Refresh')} </Button> </div> + <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> + {settingsLinks.map((item) => { + const Icon = item.icon; + return ( + <Link + key={item.href} + href={item.href} + className="group flex min-h-[116px] items-start gap-4 rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/50 hover:bg-muted/40" + > + <span className="mt-0.5 rounded-md border border-border bg-muted p-2 text-muted-foreground group-hover:text-primary"> + <Icon className="h-4 w-4" /> + </span> + <span className="min-w-0 space-y-1"> + <span className="block text-sm font-semibold text-foreground">{item.title}</span> + <span className="block text-sm leading-6 text-muted-foreground">{item.description}</span> + </span> + </Link> + ); + })} + </div> + {/* System Info */} <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> <Server className="w-4 h-4" /> - {pickAppText(locale, '系统信息', 'System information')} + {pickAppText(locale, '实例运行', 'Instance runtime')} </CardTitle> </CardHeader> <CardContent> - <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-center"> <div className="space-y-1"> - <p className="text-sm font-medium">{pickAppText(locale, '重启当前实例', 'Restart current instance')}</p> + <p className="text-sm font-medium">{pickAppText(locale, '运行与调试', 'Runtime and debugging')}</p> <p className="text-sm text-muted-foreground"> {restarting ? pickAppText(locale, '正在重启当前 docker,服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.') - : pickAppText(locale, '会重启当前 docker 容器。重启完成后需要重新登录。', 'This restarts the current Docker container. You will need to sign in again afterwards.')} + : pickAppText(locale, '查看每次对话的运行日志,或重启当前 docker 容器。重启完成后需要重新登录。', 'Inspect per-chat runtime logs or restart the current Docker container. You will need to sign in again afterwards.')} </p> {restartError ? ( <p className="text-sm text-destructive">{restartError}</p> ) : null} </div> - <AlertDialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}> - <Button - variant="destructive" - onClick={() => setRestartDialogOpen(true)} - disabled={restarting} - > - {restarting ? ( - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - ) : ( - <RefreshCw className="w-4 h-4 mr-2" /> - )} - Restart + <div className="flex flex-wrap justify-start gap-2 lg:justify-end"> + <Button asChild variant="outline"> + <Link href="/logs"> + <ScrollText className="w-4 h-4 mr-2" /> + {pickAppText(locale, '运行日志', 'Runtime Logs')} + </Link> </Button> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle> - <AlertDialogDescription> - {pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')} - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction onClick={handleRestart} disabled={restarting}> - {restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + <AlertDialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}> + <Button + variant="destructive" + onClick={() => setRestartDialogOpen(true)} + disabled={restarting} + > + {restarting ? ( + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + ) : ( + <RefreshCw className="w-4 h-4 mr-2" /> + )} + Restart + </Button> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle> + <AlertDialogDescription> + {pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel> + <AlertDialogAction onClick={handleRestart} disabled={restarting}> + {restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </div> + <div className="mt-5 grid gap-3 border-t pt-5 md:grid-cols-2"> + <InfoRow label={pickAppText(locale, '配置文件', 'Config file')} value={status.config_path} /> + <InfoRow label={pickAppText(locale, '工作区', 'Workspace')} value={status.workspace} /> </div> </CardContent> </Card> @@ -436,23 +489,6 @@ export default function StatusPage() { </CardContent> </Card> - {/* Cron Summary */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-base"> - <AlertCircle className="w-4 h-4" /> - {pickAppText(locale, '调度器', 'Scheduler')} - </CardTitle> - </CardHeader> - <CardContent className="space-y-3"> - <InfoRow - label={pickAppText(locale, '状态', 'Status')} - value={status.cron.enabled ? pickAppText(locale, '运行中', 'Running') : pickAppText(locale, '已停止', 'Stopped')} - ok={status.cron.enabled} - /> - <InfoRow label={pickAppText(locale, '任务数', 'Jobs')} value={String(status.cron.jobs)} /> - </CardContent> - </Card> </div> ); } diff --git a/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx b/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx new file mode 100644 index 0000000..ad4e5ba --- /dev/null +++ b/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx @@ -0,0 +1,621 @@ +'use client'; + +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import React, { useMemo, useState } from 'react'; +import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, RotateCcw, Trash2, User, XCircle } from 'lucide-react'; + +import { OfficeStatusBadge, formatOfficeDuration, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { cancelDelegation, deleteBackendTask, getBackendTask, getFileUrl, retryDelegation, submitChatFeedback } from '@/lib/api'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import { buildOfficeView, isOfficeTaskTerminal, type OfficeTaskView } from '@/lib/office'; +import { useChatStore } from '@/lib/store'; +import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types'; + +function taskVisibleStatus(task: OfficeTaskView, locale: 'zh-CN' | 'en-US') { + if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed'); + if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled'); + return task.stageLabel || task.status; +} + +function downloadText(filename: string, content: string) { + const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +export default function TaskDetailPage() { + const { locale } = useAppI18n(); + const router = useRouter(); + const params = useParams<{ taskId: string }>(); + const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? ''); + const sessions = useChatStore((state) => state.sessions); + const processRuns = useChatStore((state) => state.processRuns); + const processEvents = useChatStore((state) => state.processEvents); + const processArtifacts = useChatStore((state) => state.processArtifacts); + const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback); + + const task = useMemo( + () => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale), + [locale, processArtifacts, processEvents, processRuns, sessions, taskId] + ); + const [backendTask, setBackendTask] = useState<BackendTask | null>(null); + const [backendTaskLoading, setBackendTaskLoading] = useState(false); + const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null); + const [revision, setRevision] = useState(''); + const [actionError, setActionError] = useState<string | null>(null); + const [actionBusy, setActionBusy] = useState<string | null>(null); + + React.useEffect(() => { + setSelectedRunId(task?.rootRunId ?? null); + }, [task?.rootRunId]); + + React.useEffect(() => { + let cancelled = false; + if (task || !taskId) { + setBackendTask(null); + return () => { + cancelled = true; + }; + } + setBackendTaskLoading(true); + getBackendTask(taskId) + .then((item) => { + if (!cancelled) setBackendTask(item); + }) + .catch(() => { + if (!cancelled) setBackendTask(null); + }) + .finally(() => { + if (!cancelled) setBackendTaskLoading(false); + }); + return () => { + cancelled = true; + }; + }, [task, taskId]); + + const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]); + const artifacts = useMemo( + () => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)), + [processArtifacts, runIds] + ); + const eventsByRun = useMemo(() => { + const map = new Map<string, ProcessEvent[]>(); + for (const event of processEvents) { + if (!runIds.has(event.run_id)) continue; + map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]); + } + return map; + }, [processEvents, runIds]); + const artifactsByRun = useMemo(() => { + const map = new Map<string, ProcessArtifact[]>(); + for (const artifact of artifacts) { + map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]); + } + return map; + }, [artifacts]); + const phaseGroups = useMemo(() => { + const groups = new Map<string, OfficeTaskView[]>(); + for (const item of task?.tasks ?? []) { + const label = item.stageLabel || taskVisibleStatus(item, locale); + groups.set(label, [...(groups.get(label) ?? []), item]); + } + return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes })); + }, [locale, task?.tasks]); + const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null; + + const runAction = async (key: string, action: () => Promise<unknown>) => { + setActionBusy(key); + setActionError(null); + try { + await action(); + } catch (err: any) { + setActionError(err.message || pickAppText(locale, '操作失败', 'Action failed')); + } finally { + setActionBusy(null); + } + }; + + 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 (!task && backendTask) { + const validation = backendTask.validation_result; + const accepted = Boolean(validation?.accepted); + const validationIssues = [ + ...arrayOfStrings(validation?.issues), + ...arrayOfStrings(validation?.missing_requirements), + ]; + const feedbackItems = backendTask.feedback || []; + return ( + <div className="mx-auto max-w-5xl space-y-6 p-6"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <Button asChild variant="outline" className="w-fit"> + <Link href="/tasks"> + <ArrowLeft className="mr-2 h-4 w-4" /> + {pickAppText(locale, '返回任务列表', 'Back to tasks')} + </Link> + </Button> + <div className="flex items-center gap-2"> + {backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null} + <Badge>{humanTaskStatus(backendTask.status, locale)}</Badge> + <Button + variant="ghost" + size="sm" + className="text-destructive hover:text-destructive" + disabled={Boolean(actionBusy)} + onClick={() => void deleteCurrentBackendTask()} + > + <Trash2 className="mr-2 h-4 w-4" /> + {pickAppText(locale, '删除任务', 'Delete task')} + </Button> + </div> + </div> + + <Card> + <CardContent className="p-5"> + <h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1> + {backendTask.description ? ( + <p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p> + ) : null} + <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> + <span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span> + <span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span> + <span>{pickAppText(locale, '更新', 'Updated')}: {formatOfficeTime(backendTask.updated_at, locale)}</span> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle> + </CardHeader> + <CardContent className="space-y-5"> + {(backendTask.runs ?? []).length === 0 ? ( + <div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div> + ) : ( + (backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />) + )} + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle className="text-base">{pickAppText(locale, '验证和反馈', 'Validation and feedback')}</CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm"> + <div className="rounded-lg border border-border bg-muted/25 p-4"> + <div className="flex items-center gap-2"> + {validation ? ( + accepted ? <CheckCircle2 className="h-5 w-5 text-[#657162]" /> : <XCircle className="h-5 w-5 text-destructive" /> + ) : ( + <HelpCircle className="h-5 w-5 text-muted-foreground" /> + )} + <div className="font-medium"> + {validation + ? accepted + ? pickAppText(locale, '验证通过', 'Validation passed') + : pickAppText(locale, '需要继续修改', 'Needs revision') + : pickAppText(locale, '尚未验证', 'Not validated yet')} + </div> + </div> + {validation ? ( + <div className="mt-2 text-muted-foreground"> + {pickAppText(locale, '评分', 'Score')}: {String(validation.score ?? '-')} · {pickAppText(locale, '验证器', 'Validator')}: {String(validation.validator ?? '-')} + </div> + ) : null} + {validationIssues.length > 0 && ( + <ul className="mt-3 list-disc space-y-1 pl-5 text-muted-foreground"> + {validationIssues.map((item, index) => <li key={`${item}:${index}`}>{item}</li>)} + </ul> + )} + {typeof validation?.recommended_revision_prompt === 'string' && validation.recommended_revision_prompt && ( + <p className="mt-3 rounded-md bg-background p-3 text-muted-foreground">{validation.recommended_revision_prompt}</p> + )} + </div> + + <div className="space-y-2"> + <div className="font-medium">{pickAppText(locale, '用户反馈', 'User feedback')}</div> + {feedbackItems.length === 0 ? ( + <p className="text-muted-foreground">{pickAppText(locale, '还没有用户反馈。', 'No user feedback yet.')}</p> + ) : ( + feedbackItems.map((item, index) => ( + <div key={index} className="rounded-md border border-border p-3"> + <div className="font-medium">{humanFeedback(String(item.feedback_type || ''), locale)}</div> + {item.comment ? <p className="mt-1 text-muted-foreground">{String(item.comment)}</p> : null} + {item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatOfficeTime(String(item.created_at), locale)}</p> : null} + </div> + )) + )} + </div> + </CardContent> + </Card> + </div> + ); + } + + if (!task) { + return ( + <div className="mx-auto flex max-w-4xl flex-col gap-4 p-6"> + <Button asChild variant="outline" className="w-fit"> + <Link href="/tasks"> + <ArrowLeft className="mr-2 h-4 w-4" /> + {pickAppText(locale, '返回任务列表', 'Back to tasks')} + </Link> + </Button> + <Card className="border-dashed"> + <CardContent className="py-16 text-center"> + <h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1> + <p className="mt-2 text-sm text-muted-foreground"> + {backendTaskLoading + ? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.') + : pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')} + </p> + </CardContent> + </Card> + </div> + ); + } + + const progressValue = progressPercent(task.progress.value, task.progress.max); + + return ( + <div className="mx-auto max-w-7xl space-y-6 p-6"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="flex flex-wrap items-center gap-2"> + <Button asChild variant="outline" size="sm"> + <Link href="/tasks"> + <ArrowLeft className="mr-2 h-4 w-4" /> + {pickAppText(locale, '返回任务', 'Back to tasks')} + </Link> + </Button> + <Button asChild variant="ghost" size="sm"> + <Link href="/"> + <MessageSquare className="mr-2 h-4 w-4" /> + {pickAppText(locale, '对话', 'Chat')} + </Link> + </Button> + </div> + <div className="flex flex-wrap items-center gap-2"> + <Button + variant="outline" + size="sm" + disabled={Boolean(actionBusy) || isOfficeTaskTerminal(task.status)} + onClick={() => void runAction('cancel', () => cancelDelegation(task.rootRunId))} + > + <XCircle className="mr-2 h-4 w-4" /> + {pickAppText(locale, '取消任务', 'Cancel task')} + </Button> + <Button + size="sm" + disabled={Boolean(actionBusy) || !isOfficeTaskTerminal(task.status)} + onClick={() => void runAction('retry', () => retryDelegation(task.rootRunId))} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '重试任务', 'Retry task')} + </Button> + </div> + </div> + + <Card> + <CardContent className="p-5"> + <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-3"> + <h1 className="truncate text-2xl font-semibold">{task.title}</h1> + <OfficeStatusBadge status={task.status} /> + </div> + <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> + <span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span> + <span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span> + <span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(task.createdAt, locale)}</span> + <span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(task.durationMs, locale)}</span> + </div> + </div> + <div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]"> + <Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} /> + <Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} /> + <Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} /> + <Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.alerts.length)} /> + </div> + </div> + <div className="mt-5 space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">{task.progress.label}</span> + <span className="font-medium">{progressValue}%</span> + </div> + <div className="h-2 overflow-hidden rounded-full bg-secondary"> + <div className="h-full bg-primary" style={{ width: `${progressValue}%` }} /> + </div> + </div> + </CardContent> + </Card> + + {actionError && ( + <Card className="border-destructive"> + <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive"> + <AlertCircle className="h-4 w-4" /> + {actionError} + </CardContent> + </Card> + )} + + <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]"> + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {phaseGroups.map((phase, index) => ( + <div key={`${phase.label}:${index}`} className="flex items-center gap-2"> + <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm"> + <div className="font-medium">{phase.label}</div> + <div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div> + </div> + {index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null} + </div> + ))} + </div> + </CardContent> + </Card> + + {phaseGroups.map((phase) => ( + <Card key={phase.label}> + <CardHeader> + <CardTitle className="text-base">{phase.label}</CardTitle> + </CardHeader> + <CardContent className="grid gap-3 md:grid-cols-2"> + {phase.nodes.map((node) => ( + <button + key={node.runId} + type="button" + onClick={() => setSelectedRunId(node.runId)} + className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`} + > + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <div className="truncate font-medium">{node.title}</div> + <div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div> + </div> + <OfficeStatusBadge status={node.status} /> + </div> + <div className="mt-3 text-sm text-muted-foreground"> + {node.summary || taskVisibleStatus(node, locale)} + </div> + <div className="mt-3 flex gap-3 text-xs text-muted-foreground"> + <span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span> + <span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span> + </div> + </button> + ))} + </CardContent> + </Card> + ))} + </div> + + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle> + </CardHeader> + <CardContent> + {selectedNode ? ( + <div className="space-y-4"> + <div> + <div className="font-medium">{selectedNode.title}</div> + <div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div> + </div> + <OfficeStatusBadge status={selectedNode.status} /> + <p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p> + <div className="space-y-2"> + {(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => ( + <div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs"> + <div className="font-medium">{event.kind}</div> + <div className="mt-1 text-muted-foreground">{event.text || formatOfficeTime(event.created_at, locale)}</div> + </div> + ))} + </div> + </div> + ) : ( + <p className="text-sm text-muted-foreground">-</p> + )} + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle className="text-base">{pickAppText(locale, '修订意见', 'Revision')}</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <Textarea + value={revision} + onChange={(event) => setRevision(event.target.value)} + placeholder={pickAppText(locale, '直接写下需要调整的地方...', 'Describe what should change...')} + /> + <Button + className="w-full" + disabled={!revision.trim() || Boolean(actionBusy)} + onClick={() => + void runAction('revision', async () => { + updateMessageFeedback(task.rootRunId, 'revise'); + await submitChatFeedback({ + sessionId: task.sessionId || 'web:default', + runId: task.rootRunId, + feedbackType: 'revise', + comment: revision.trim(), + }); + setRevision(''); + }) + } + > + <RefreshCw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '提交修订', 'Submit revision')} + </Button> + </CardContent> + </Card> + + <Card> + <CardHeader> + <div className="flex items-center justify-between gap-3"> + <CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle> + <Button + size="sm" + variant="outline" + disabled={artifacts.length === 0} + onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))} + > + <Download className="mr-2 h-4 w-4" /> + {pickAppText(locale, '全部下载', 'Download all')} + </Button> + </div> + </CardHeader> + <CardContent className="space-y-2"> + {artifacts.length === 0 ? ( + <p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p> + ) : ( + artifacts.map((artifact) => ( + <div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3"> + <div className="min-w-0"> + <div className="flex items-center gap-2 text-sm font-medium"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="truncate">{artifact.title}</span> + </div> + <div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div> + </div> + {artifact.url || artifact.file_id ? ( + <Button asChild size="sm" variant="outline"> + <a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer"> + <Download className="mr-2 h-3.5 w-3.5" /> + {pickAppText(locale, '下载', 'Download')} + </a> + </Button> + ) : ( + <Button + size="sm" + variant="outline" + onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))} + > + <Download className="mr-2 h-3.5 w-3.5" /> + {pickAppText(locale, '下载', 'Download')} + </Button> + )} + </div> + )) + )} + </CardContent> + </Card> + </div> + </div> + </div> + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + <div className="rounded-md border border-border bg-muted/30 px-3 py-3"> + <div className="text-xs text-muted-foreground">{label}</div> + <div className="mt-1 text-lg font-semibold">{value}</div> + </div> + ); +} + +function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) { + const { locale } = useAppI18n(); + return ( + <div className="rounded-lg border border-border bg-background p-4"> + <div className="mb-4 flex flex-wrap items-center justify-between gap-3"> + <div> + <div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div> + <div className="mt-1 text-xs text-muted-foreground"> + {run.started_at ? formatOfficeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')} + {run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''} + </div> + </div> + <Badge variant={run.success === false ? 'destructive' : 'secondary'}> + {run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')} + </Badge> + </div> + + {run.messages.length === 0 ? ( + <p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p> + ) : ( + <div className="space-y-3"> + {run.messages.map((message, messageIndex) => { + const isAssistant = message.role === 'assistant'; + const isTool = message.role === 'tool'; + const Icon = isAssistant ? Bot : isTool ? FileText : User; + return ( + <div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3"> + <div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted"> + <Icon className="h-4 w-4 text-muted-foreground" /> + </div> + <div className="min-w-0 flex-1"> + <div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground"> + <span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span> + {message.created_at ? <span>{formatOfficeTime(message.created_at, locale)}</span> : null} + </div> + <div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6"> + {message.content} + </div> + </div> + </div> + ); + })} + </div> + )} + </div> + ); +} + +function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') { + const map: Record<string, [string, string]> = { + open: ['已创建', 'Open'], + running: ['执行中', 'Running'], + validating: ['验证中', 'Validating'], + awaiting_feedback: ['等待反馈', 'Awaiting feedback'], + needs_revision: ['需要修改', 'Needs revision'], + closed: ['已完成', 'Closed'], + abandoned: ['已放弃', 'Abandoned'], + }; + const item = map[status]; + return item ? pickAppText(locale, item[0], item[1]) : status; +} + +function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') { + if (type === 'satisfied') return pickAppText(locale, '满意', 'Satisfied'); + if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested'); + if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned'); + return type || pickAppText(locale, '反馈', 'Feedback'); +} + +function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') { + if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed'); + if (reason === 'error') return pickAppText(locale, '执行出错', 'Error'); + if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled'); + return reason; +} + +function arrayOfStrings(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : []; +} diff --git a/app-instance/frontend/app/(app)/tasks/page.tsx b/app-instance/frontend/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..c05f985 --- /dev/null +++ b/app-instance/frontend/app/(app)/tasks/page.tsx @@ -0,0 +1,466 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import React, { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, ArrowRight, Clock3, FolderDown, ListTodo, Loader2, Play, Plus, RefreshCw, Trash2, X } from 'lucide-react'; + +import { formatOfficeTime } from '@/components/office/OfficeShared'; +import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { addCronJob, deleteBackendTask, listBackendTasks, listCronJobs, removeCronJob, runCronJob, toggleCronJob } from '@/lib/api'; +import { pickAppText } from '@/lib/i18n/core'; +import { useAppI18n } from '@/lib/i18n/provider'; +import { useChatStore } from '@/lib/store'; +import type { BackendTask, CronJob } from '@/types'; + +export default function TasksPage() { + const { locale } = useAppI18n(); + const searchParams = useSearchParams(); + const tab = searchParams.get('tab') === 'scheduled' ? 'scheduled' : 'ordinary'; + + return ( + <div className="mx-auto max-w-7xl space-y-6 p-6"> + <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> + <div> + <h1 className="text-2xl font-semibold tracking-normal">{pickAppText(locale, '任务', 'Tasks')}</h1> + <p className="mt-2 max-w-2xl text-sm text-muted-foreground"> + {pickAppText(locale, '普通任务展示用户发起或接入的工作任务;定时任务触发后会先进入通知,需要修改时再接入 Task。', 'Ordinary tasks show user-started or engaged work. Scheduled jobs first create notifications and only become Tasks when revised.')} + </p> + </div> + <TaskManagementTabs /> + </div> + + {tab === 'scheduled' ? <ScheduledTasks /> : <OrdinaryTasks />} + </div> + ); +} + +function OrdinaryTasks() { + const { locale } = useAppI18n(); + const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]); + const [error, setError] = useState<string | null>(null); + + const visibleTasks = useMemo( + () => backendTasks.filter((task) => { + if (task.creator !== 'cron') return true; + return Boolean(task.metadata?.user_engaged || task.metadata?.requires_followup); + }), + [backendTasks] + ); + + const loadBackendTasks = React.useCallback(() => { + let cancelled = false; + listBackendTasks() + .then((items) => { + if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []); + }) + .catch(() => { + if (!cancelled) setBackendTasks([]); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => loadBackendTasks(), [loadBackendTasks]); + + const handleDeleteBackendTask = async (task: BackendTask) => { + const title = task.short_title || task.description || task.goal || task.task_id; + if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) { + return; + } + setError(null); + try { + await deleteBackendTask(task.task_id); + setBackendTasks((items) => items.filter((item) => item.task_id !== task.task_id)); + } catch (err: any) { + setError(err.message || pickAppText(locale, '删除任务失败', 'Failed to delete task')); + } + }; + + if (visibleTasks.length === 0) { + return ( + <Card className="border-dashed"> + <CardContent className="flex flex-col items-center justify-center py-16 text-center"> + <ListTodo className="h-10 w-10 text-muted-foreground/50" /> + <h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '暂无普通任务', 'No ordinary tasks yet')}</h2> + <p className="mt-2 max-w-xl text-sm text-muted-foreground"> + {pickAppText(locale, '从对话页发起复杂任务后,这里会保留普通任务列表;即使来源会话被归档,任务仍会显示。', 'Complex tasks created from chat appear here. Tasks remain visible even when their source session is archived.')} + </p> + <Button asChild className="mt-6"> + <Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link> + </Button> + </CardContent> + </Card> + ); + } + + return ( + <div className="space-y-3"> + {error && ( + <Card className="border-destructive"> + <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive"> + <AlertCircle className="h-4 w-4" /> + {error} + </CardContent> + </Card> + )} + <Card> + <CardContent className="p-0"> + <Table> + <TableHeader> + <TableRow> + <TableHead>{pickAppText(locale, '任务', 'Task')}</TableHead> + <TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead> + <TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead> + <TableHead>{pickAppText(locale, '运行次数', 'Runs')}</TableHead> + <TableHead>{pickAppText(locale, '使用技能', 'Skills')}</TableHead> + <TableHead>{pickAppText(locale, '更新时间', 'Updated')}</TableHead> + <TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {visibleTasks.map((task) => ( + <TableRow key={task.task_id}> + <TableCell> + <div className="max-w-[360px]"> + <div className="flex min-w-0 items-center gap-2"> + <div className="truncate font-medium">{task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id}</div> + {task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null} + </div> + <div className="mt-1 truncate text-xs text-muted-foreground"> + {task.description || task.session_id} · {task.creator} + </div> + </div> + </TableCell> + <TableCell> + <Badge variant={task.status === 'awaiting_feedback' || task.status === 'closed' ? 'default' : 'secondary'}> + {taskStatusLabel(task.status, locale)} + </Badge> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground">{taskSourceLabel(task, locale)}</span> + </TableCell> + <TableCell className="text-sm text-muted-foreground">{task.run_ids.length}</TableCell> + <TableCell className="text-sm text-muted-foreground">{task.skill_names.length}</TableCell> + <TableCell className="text-xs text-muted-foreground">{formatOfficeTime(task.updated_at, locale)}</TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button asChild size="sm" variant="outline"> + <Link href={`/tasks/${encodeURIComponent(task.task_id)}`}> + {pickAppText(locale, '进入', 'Open')} + <ArrowRight className="ml-2 h-3.5 w-3.5" /> + </Link> + </Button> + <Button + size="icon" + variant="ghost" + className="h-8 w-8 text-destructive hover:text-destructive" + onClick={() => void handleDeleteBackendTask(task)} + title={pickAppText(locale, '删除任务', 'Delete task')} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + </div> + ); +} + +function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') { + const labels: Record<string, [string, string]> = { + open: ['已创建', 'Open'], + running: ['执行中', 'Running'], + validating: ['验证中', 'Validating'], + awaiting_feedback: ['等待反馈', 'Awaiting feedback'], + needs_revision: ['需要修改', 'Needs revision'], + closed: ['已完成', 'Closed'], + abandoned: ['已放弃', 'Abandoned'], + }; + const label = labels[status]; + return label ? pickAppText(locale, label[0], label[1]) : status; +} + +function taskSourceLabel(task: BackendTask, locale: 'zh-CN' | 'en-US') { + if (task.metadata?.source === 'scheduled_run') { + return pickAppText(locale, '定时通知修改', 'Scheduled notification revision'); + } + if (task.metadata?.source === 'scheduled_cron') { + return pickAppText(locale, '定时任务', 'Scheduled task'); + } + if (task.creator === 'cron') { + return pickAppText(locale, '定时任务', 'Scheduled task'); + } + return pickAppText(locale, '对话任务', 'Chat task'); +} + +function ScheduledTasks() { + const { locale } = useAppI18n(); + const sessionId = useChatStore((state) => state.sessionId); + const [jobs, setJobs] = useState<CronJob[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [showAdd, setShowAdd] = useState(false); + const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default'; + + const loadJobs = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setJobs(await listCronJobs(true)); + } catch (err: any) { + setError(err.message || pickAppText(locale, '加载定时任务失败', 'Failed to load scheduled tasks')); + } finally { + setLoading(false); + } + }, [locale]); + + useEffect(() => { + void loadJobs(); + }, [loadJobs]); + + const formatTime = (ms: number | null) => { + if (!ms) return '-'; + return new Date(ms).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + }; + + const runJobAction = async (action: () => Promise<unknown>) => { + try { + await action(); + await loadJobs(); + } catch (err: any) { + setError(err.message || pickAppText(locale, '操作失败', 'Action failed')); + } + }; + + return ( + <div className="space-y-4"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Clock3 className="h-4 w-4" /> + {pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')} + </div> + <div className="flex items-center gap-2"> + <Button onClick={() => void loadJobs()} variant="outline" size="sm"> + <RefreshCw className="mr-2 h-4 w-4" /> + {pickAppText(locale, '刷新', 'Refresh')} + </Button> + <Button onClick={() => setShowAdd(true)} size="sm"> + <Plus className="mr-2 h-4 w-4" /> + {pickAppText(locale, '新建定时任务', 'New scheduled task')} + </Button> + </div> + </div> + + {error && ( + <Card className="border-destructive"> + <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive"> + <AlertCircle className="h-4 w-4" /> + {error} + </CardContent> + </Card> + )} + + {showAdd && ( + <AddJobForm + targetSessionKey={targetSessionKey} + onCancel={() => setShowAdd(false)} + onAdd={(params) => + runJobAction(async () => { + await addCronJob({ ...params, session_key: targetSessionKey, mode: 'notification' }); + setShowAdd(false); + }) + } + /> + )} + + <Card> + <CardContent className="p-0"> + {loading ? ( + <div className="flex items-center justify-center py-16 text-muted-foreground"> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {pickAppText(locale, '加载中', 'Loading')} + </div> + ) : jobs.length === 0 ? ( + <div className="py-14 text-center text-muted-foreground"> + <Clock3 className="mx-auto mb-3 h-10 w-10 opacity-40" /> + <p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead> + <TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead> + <TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead> + <TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead> + <TableHead>{pickAppText(locale, '运行历史', 'History')}</TableHead> + <TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead> + <TableHead className="w-28">{pickAppText(locale, '操作', 'Actions')}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {jobs.map((job) => ( + <TableRow key={job.id}> + <TableCell> + <Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} /> + </TableCell> + <TableCell> + <div className="font-medium">{job.name}</div> + <div className="text-xs text-muted-foreground">{job.id}</div> + </TableCell> + <TableCell> + <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{job.schedule_display}</code> + <div className="mt-1 text-xs text-muted-foreground">{pickAppText(locale, '下次', 'Next')}: {formatTime(job.next_run_at_ms)}</div> + </TableCell> + <TableCell> + <span className="block max-w-[260px] truncate text-sm">{job.message}</span> + </TableCell> + <TableCell> + <Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}> + <Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}> + <FolderDown className="mr-2 h-3.5 w-3.5" /> + {formatTime(job.last_run_at_ms)} + </Link> + </Button> + </TableCell> + <TableCell> + {job.last_status === 'ok' ? ( + <Badge>{pickAppText(locale, '成功', 'OK')}</Badge> + ) : job.last_status === 'error' ? ( + <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge> + ) : ( + <span className="text-xs text-muted-foreground">-</span> + )} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}> + <Play className="h-3.5 w-3.5" /> + </Button> + <Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}> + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </CardContent> + </Card> + </div> + ); +} + +function AddJobForm({ + targetSessionKey, + onAdd, + onCancel, +}: { + targetSessionKey: string; + onAdd: (params: { name: string; message: string; every_seconds?: number; cron_expr?: string; at_iso?: string }) => void; + onCancel: () => void; +}) { + const { locale } = useAppI18n(); + const [name, setName] = useState(''); + const [message, setMessage] = useState(''); + const [scheduleType, setScheduleType] = useState<'every' | 'cron' | 'at'>('every'); + const [everySeconds, setEverySeconds] = useState('3600'); + const [cronExpr, setCronExpr] = useState('0 9 * * *'); + const [atIso, setAtIso] = useState(''); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!name.trim() || !message.trim()) return; + if (scheduleType === 'every') { + onAdd({ name: name.trim(), message: message.trim(), every_seconds: Number.parseInt(everySeconds, 10) || 3600 }); + return; + } + if (scheduleType === 'cron') { + onAdd({ name: name.trim(), message: message.trim(), cron_expr: cronExpr.trim() }); + return; + } + onAdd({ name: name.trim(), message: message.trim(), at_iso: atIso }); + }; + + return ( + <Card> + <CardHeader className="pb-4"> + <div className="flex items-center justify-between"> + <CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle> + <Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}> + <X className="h-4 w-4" /> + </Button> + </div> + </CardHeader> + <CardContent> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="grid gap-4 md:grid-cols-2"> + <div className="space-y-2"> + <Label htmlFor="job-name">{pickAppText(locale, '任务名称', 'Task name')}</Label> + <Input id="job-name" value={name} onChange={(event) => setName(event.target.value)} /> + </div> + <div className="space-y-2"> + <Label>{pickAppText(locale, '调度类型', 'Schedule type')}</Label> + <Select value={scheduleType} onValueChange={(value) => setScheduleType(value as 'every' | 'cron' | 'at')}> + <SelectTrigger><SelectValue /></SelectTrigger> + <SelectContent> + <SelectItem value="every">{pickAppText(locale, '固定间隔', 'Fixed interval')}</SelectItem> + <SelectItem value="cron">Cron</SelectItem> + <SelectItem value="at">{pickAppText(locale, '一次性', 'One-time')}</SelectItem> + </SelectContent> + </Select> + </div> + </div> + {scheduleType === 'every' ? ( + <div className="space-y-2"> + <Label htmlFor="every-seconds">{pickAppText(locale, '间隔秒数', 'Interval seconds')}</Label> + <Input id="every-seconds" type="number" min="10" value={everySeconds} onChange={(event) => setEverySeconds(event.target.value)} /> + </div> + ) : scheduleType === 'cron' ? ( + <div className="space-y-2"> + <Label htmlFor="cron-expr">Cron</Label> + <Input id="cron-expr" value={cronExpr} onChange={(event) => setCronExpr(event.target.value)} /> + </div> + ) : ( + <div className="space-y-2"> + <Label htmlFor="at-iso">{pickAppText(locale, '触发时间', 'Run at')}</Label> + <Input id="at-iso" type="datetime-local" value={atIso} onChange={(event) => setAtIso(event.target.value)} /> + </div> + )} + <div className="space-y-2"> + <Label htmlFor="job-message">{pickAppText(locale, '任务消息', 'Task message')}</Label> + <Input id="job-message" value={message} onChange={(event) => setMessage(event.target.value)} /> + <p className="text-xs text-muted-foreground"> + {pickAppText(locale, '触发后会发送到固定通知 session;需要修改时再接入 Task。来源会话:', 'Triggers are sent to the fixed notification session; connect to a Task for revisions. Source session: ')} + <code className="rounded bg-muted px-1 py-0.5">{targetSessionKey}</code> + </p> + </div> + <div className="flex justify-end gap-2"> + <Button type="button" variant="outline" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button> + <Button type="submit" disabled={!name.trim() || !message.trim()}> + <Plus className="mr-2 h-4 w-4" /> + {pickAppText(locale, '创建', 'Create')} + </Button> + </div> + </form> + </CardContent> + </Card> + ); +} diff --git a/app-instance/frontend/app/globals.css b/app-instance/frontend/app/globals.css index 9128099..9b2464e 100644 --- a/app-instance/frontend/app/globals.css +++ b/app-instance/frontend/app/globals.css @@ -18,31 +18,31 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; + --background: 0 0% 99%; + --foreground: 0 0% 4%; --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card-foreground: 0 0% 4%; --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; + --popover-foreground: 0 0% 4%; + --primary: 15 16% 10%; + --primary-foreground: 0 0% 99%; + --secondary: 30 10% 94%; + --secondary-foreground: 15 16% 10%; + --muted: 24 9% 91%; + --muted-foreground: 20 8% 46%; + --accent: 30 8% 95%; + --accent-foreground: 15 16% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; + --border: 24 8% 88%; + --input: 0 0% 100%; + --ring: 18 9% 52%; + --chart-1: 17 9% 51%; + --chart-2: 107 9% 55%; + --chart-3: 216 12% 59%; + --chart-4: 18 8% 68%; + --chart-5: 102 12% 74%; + --radius: 1rem; } .dark { --background: 0 0% 3.9%; @@ -78,8 +78,13 @@ } body { @apply bg-background text-foreground; - font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", - "Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: "Public Sans", Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Noto Sans SC", "Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; + } + + h1, h2 { + font-family: "Lora", Georgia, "Times New Roman", serif; } } diff --git a/app-instance/frontend/app/layout.tsx b/app-instance/frontend/app/layout.tsx index a0d7a59..41cee15 100644 --- a/app-instance/frontend/app/layout.tsx +++ b/app-instance/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import './globals.css'; import type { Metadata } from 'next'; +import type { CSSProperties } from 'react'; import { AppI18nProvider } from '@/lib/i18n/provider'; import { getServerAppLocale } from '@/lib/i18n/server'; @@ -17,9 +18,29 @@ export default function RootLayout({ children: React.ReactNode; }) { const locale = getServerAppLocale(); + const taupeTheme = { + '--background': '0 0% 99%', + '--foreground': '0 0% 4%', + '--card': '0 0% 100%', + '--card-foreground': '0 0% 4%', + '--popover': '0 0% 100%', + '--popover-foreground': '0 0% 4%', + '--primary': '15 16% 10%', + '--primary-foreground': '0 0% 99%', + '--secondary': '30 10% 94%', + '--secondary-foreground': '15 16% 10%', + '--muted': '24 9% 91%', + '--muted-foreground': '20 8% 46%', + '--accent': '30 8% 95%', + '--accent-foreground': '15 16% 10%', + '--border': '24 8% 88%', + '--input': '0 0% 100%', + '--ring': '18 9% 52%', + '--radius': '1rem', + } as CSSProperties; return ( - <html lang={locale} className="dark"> + <html lang={locale} style={taupeTheme}> <body className="bg-background text-foreground"> <AppI18nProvider initialLocale={locale}>{children}</AppI18nProvider> </body> diff --git a/app-instance/frontend/components/AppRuntimeBridge.tsx b/app-instance/frontend/components/AppRuntimeBridge.tsx index b338205..043a31a 100644 --- a/app-instance/frontend/components/AppRuntimeBridge.tsx +++ b/app-instance/frontend/components/AppRuntimeBridge.tsx @@ -78,8 +78,7 @@ export function AppRuntimeBridge() { React.useEffect(() => { resetProcessState(); - const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId; - wsManager.connect(wsSessionId); + wsManager.connect(sessionId); }, [resetProcessState, sessionId]); React.useEffect(() => { diff --git a/app-instance/frontend/components/AppShell.tsx b/app-instance/frontend/components/AppShell.tsx new file mode 100644 index 0000000..dac1275 --- /dev/null +++ b/app-instance/frontend/components/AppShell.tsx @@ -0,0 +1,21 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import Header from '@/components/Header'; +import AuthGuard from '@/components/AuthGuard'; +import { AppRuntimeBridge } from '@/components/AppRuntimeBridge'; + +export function AppShell({ children }: { children: ReactNode }) { + return ( + <div className="min-h-screen bg-background text-foreground"> + <Header /> + <main className="pt-16"> + <AuthGuard> + <AppRuntimeBridge /> + {children} + </AuthGuard> + </main> + </div> + ); +} diff --git a/app-instance/frontend/components/Header.tsx b/app-instance/frontend/components/Header.tsx index 02022bc..c6f37dc 100644 --- a/app-instance/frontend/components/Header.tsx +++ b/app-instance/frontend/components/Header.tsx @@ -2,9 +2,8 @@ import React from 'react'; import Link from 'next/link'; -import Image from 'next/image'; import { usePathname, useRouter } from 'next/navigation'; -import { MessageSquare, Activity, Clock, Puzzle, Blocks, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, ChevronDown } from 'lucide-react'; +import { Bell, Bot, ChevronDown, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react'; import { logout } from '@/lib/api'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; @@ -16,17 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider'; import { useChatStore } from '@/lib/store'; type NavItem = { - key: - | 'chat' - | 'status' - | 'office' - | 'skills' - | 'plugins' - | 'agents' - | 'mcp' - | 'outlook' - | 'marketplace' - | 'files'; + key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings'; href: string; icon: React.ComponentType<{ className?: string }>; matchPrefixes?: string[]; @@ -34,22 +23,22 @@ type NavItem = { const NAV_ITEMS: NavItem[] = [ { key: 'chat', href: '/', icon: MessageSquare }, - { key: 'status', href: '/status', icon: Activity }, - { key: 'office', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] }, + { key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] }, + { key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] }, { key: 'skills', href: '/skills', icon: Puzzle }, - { key: 'plugins', href: '/plugins', icon: Blocks }, - { key: 'agents', href: '/agents', icon: Bot }, - { key: 'mcp', href: '/mcp', icon: ServerCog }, - { key: 'outlook', href: '/outlook', icon: Mail }, - { key: 'marketplace', href: '/marketplace', icon: Store }, - { key: 'files', href: '/files', icon: FolderOpen }, + { key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] }, + { key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] }, + { key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] }, + { key: 'marketplace', href: '/marketplace', icon: Store, matchPrefixes: ['/marketplace'] }, + { key: 'plugins', href: '/plugins', icon: PackageOpen, matchPrefixes: ['/plugins'] }, + { + key: 'settings', + href: '/settings', + icon: Settings, + matchPrefixes: ['/settings', '/status', '/logs'], + }, ]; -const AUTH_ITEMS = [ - { key: 'login', href: '/login', icon: LogIn }, - { key: 'register', href: '/register', icon: UserPlus }, -] as const; - function ConnectionDot() { const { locale } = useAppI18n(); const wsStatus = useChatStore((s) => s.wsStatus); @@ -61,10 +50,10 @@ function ConnectionDot() { const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false); const color = isOnline - ? 'bg-green-500' + ? 'bg-[#869683]' : isConnecting - ? 'bg-yellow-500' - : 'bg-red-500'; + ? 'bg-[#8B7E77]' + : 'bg-[#5F5550]'; const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale); @@ -86,23 +75,17 @@ const Header = () => { const navLabel = React.useCallback((key: NavItem['key']) => { if (key === 'chat') return pickAppText(locale, '对话', 'Chat'); - if (key === 'status') return pickAppText(locale, '状态', 'Status'); - if (key === 'office') return pickAppText(locale, '任务管理', 'Tasks'); + if (key === 'tasks') return 'Task'; + if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications'); if (key === 'skills') return pickAppText(locale, '技能', 'Skills'); - if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins'); + if (key === 'tools') return pickAppText(locale, '工具', 'Tools'); if (key === 'agents') return pickAppText(locale, '智能体', 'Agents'); - if (key === 'mcp') return 'MCP'; if (key === 'outlook') return 'Outlook'; if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace'); - return pickAppText(locale, '文件', 'Files'); + if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins'); + return pickAppText(locale, '配置', 'Settings'); }, [locale]); - const authLabel = React.useCallback((key: 'login' | 'register') => ( - key === 'login' - ? pickAppText(locale, '登录', 'Sign In') - : pickAppText(locale, '注册', 'Sign Up') - ), [locale]); - const handleLogout = async () => { await logout(); setUser(null); @@ -113,24 +96,16 @@ const Header = () => { const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase(); return ( - <header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50"> - <div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10"> - <div className="flex items-center h-16 gap-6"> - <Link href="/" className="flex shrink-0 items-center gap-3 pr-2"> - <Image - src="/boardware-logo.jpg" - alt="Boardware logo" - width={40} - height={32} - className="h-8 w-10 shrink-0 rounded-sm bg-white object-contain p-0.5" - /> - <span className="whitespace-nowrap text-[1.05rem] font-semibold leading-none tracking-tight sm:text-[1.15rem]"> - Boardware Agent Sandbox + <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="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4"> + <Link href="/" className="flex shrink-0 items-center"> + <span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]"> + Beaver </span> </Link> - <div className="flex min-w-0 flex-1 items-center justify-end gap-3"> - <nav className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> + <nav className="flex 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)]"> {NAV_ITEMS.map((item) => { const isActive = item.href === '/' @@ -141,10 +116,10 @@ const Header = () => { <Link key={item.href} href={item.href} - className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${ + className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${ isActive ? 'bg-primary text-primary-foreground' - : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' + : 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]' }`} > <Icon className="w-4 h-4" /> @@ -154,26 +129,30 @@ const Header = () => { })} </nav> - <div className="flex shrink-0 items-center gap-2 border-l border-border pl-4"> + <div className="flex min-w-0 items-center justify-end gap-3"> + <div className="hidden shrink-0 sm:block"> + <ConnectionDot /> + </div> + <div className="flex shrink-0 items-center gap-2"> <LanguageSwitcher /> {user ? ( <Popover> <PopoverTrigger asChild> <button type="button" - className="flex items-center gap-2 rounded-full border border-border/70 bg-background px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground" + className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]" > - <Avatar className="h-8 w-8 border border-border/60"> + <Avatar className="h-8 w-8 border border-[#E6E1DE]"> <AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground"> {userInitial} </AvatarFallback> - </Avatar> - <span className="hidden max-w-28 truncate sm:block">{user.username}</span> - <ChevronDown className="h-4 w-4 text-muted-foreground" /> + </Avatar> + <span className="hidden max-w-28 truncate sm:block">{user.username}</span> + <ChevronDown className="h-4 w-4 text-muted-foreground" /> </button> </PopoverTrigger> <PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl"> - <div className="overflow-hidden rounded-3xl bg-gradient-to-b from-slate-50 via-slate-50 to-white"> + <div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]"> <div className="border-b border-border/60 px-6 py-5"> <p className="truncate text-center text-sm font-medium text-muted-foreground"> {user.email} @@ -210,30 +189,7 @@ const Header = () => { </div> </PopoverContent> </Popover> - ) : !isAuthLoading ? ( - AUTH_ITEMS.map((item) => { - const isActive = pathname.startsWith(item.href); - const Icon = item.icon; - return ( - <Link - key={item.href} - href={item.href} - className={`flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${ - isActive - ? 'bg-primary text-primary-foreground' - : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' - }`} - > - <Icon className="w-4 h-4" /> - {authLabel(item.key)} - </Link> - ); - }) - ) : null} - </div> - - <div className="shrink-0 border-l border-border pl-4"> - <ConnectionDot /> + ) : !isAuthLoading ? null : null} </div> </div> </div> diff --git a/app-instance/frontend/components/chat-workbench/AgentTeamBlock.tsx b/app-instance/frontend/components/chat-workbench/AgentTeamBlock.tsx index b4f04ab..555efc9 100644 --- a/app-instance/frontend/components/chat-workbench/AgentTeamBlock.tsx +++ b/app-instance/frontend/components/chat-workbench/AgentTeamBlock.tsx @@ -25,28 +25,28 @@ const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cance const AGENT_ACCENTS = [ { - frame: 'border-sky-500/25 bg-sky-500/[0.05]', - title: 'text-sky-300', - dot: 'bg-sky-400', - result: 'border-sky-500/25 bg-sky-500/[0.08]', + frame: 'border-[#BCC4CE] bg-[#E4E7EB]/45', + title: 'text-[#697281]', + dot: 'bg-[#8C96A3]', + result: 'border-[#BCC4CE] bg-[#E4E7EB]/55', }, { - frame: 'border-emerald-500/25 bg-emerald-500/[0.05]', - title: 'text-emerald-300', - dot: 'bg-emerald-400', - result: 'border-emerald-500/25 bg-emerald-500/[0.08]', + frame: 'border-[#B7C2B5] bg-[#E3E8E2]/45', + title: 'text-[#657162]', + dot: 'bg-[#869683]', + result: 'border-[#B7C2B5] bg-[#E3E8E2]/55', }, { - frame: 'border-amber-500/25 bg-amber-500/[0.05]', - title: 'text-amber-300', - dot: 'bg-amber-400', - result: 'border-amber-500/25 bg-amber-500/[0.08]', + frame: 'border-[#B8AEA8] bg-[#E7E2DE]/55', + title: 'text-[#5F5550]', + dot: 'bg-[#8B7E77]', + result: 'border-[#B8AEA8] bg-[#E7E2DE]/65', }, { - frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]', - title: 'text-fuchsia-300', - dot: 'bg-fuchsia-400', - result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]', + frame: 'border-[#D8D2CE] bg-[#ECE8E5]/70', + title: 'text-[#4F4642]', + dot: 'bg-[#6A5E58]', + result: 'border-[#D8D2CE] bg-[#ECE8E5]/80', }, ] as const; @@ -55,12 +55,12 @@ function accentFor(index: number) { } function statusTone(status: ProcessRun['status']) { - if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300'; - if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300'; - if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300'; - if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300'; - if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300'; - return 'border-sky-500/20 bg-sky-500/10 text-sky-300'; + if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]'; + if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]'; + if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]'; + if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]'; + if (status === 'queued') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]'; + return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]'; } function feedTone(role: AgentFeedItem['role']) { @@ -166,8 +166,8 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) { const rawEphemeral = metadata?.ephemeral_skill_names; const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : []; const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : []; - const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : ''; - if (selected.length === 0 && ephemeral.length === 0 && !draftId) { + const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : ''; + if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) { return null; } return ( @@ -182,9 +182,9 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) { ephemeral:{name} </Badge> ))} - {draftId && ( + {guidanceId && ( <Badge variant="outline" className="text-[10px]"> - draft:{draftId.slice(0, 8)} + guidance:{guidanceId.slice(0, 8)} </Badge> )} </div> @@ -390,7 +390,7 @@ function ResultCard({ <div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">{pickAppText(locale, '结果', 'Result')}</div> <div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div> </div> - <CheckCircle2 className="h-4 w-4 text-emerald-400" /> + <CheckCircle2 className="h-4 w-4 text-[#657162]" /> </div> <div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div> <div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground"> diff --git a/app-instance/frontend/components/chat-workbench/ArtifactSidebar.tsx b/app-instance/frontend/components/chat-workbench/ArtifactSidebar.tsx index bd94a4d..3a8db41 100644 --- a/app-instance/frontend/components/chat-workbench/ArtifactSidebar.tsx +++ b/app-instance/frontend/components/chat-workbench/ArtifactSidebar.tsx @@ -28,7 +28,7 @@ function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US' } if (artifact.artifact_type === 'link' && artifact.url) { return ( - <a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-sky-300 underline break-all"> + <a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-[#5F5550] underline break-all"> {artifact.url} </a> ); diff --git a/app-instance/frontend/components/chat-workbench/ChatWorkbench.tsx b/app-instance/frontend/components/chat-workbench/ChatWorkbench.tsx index 376a244..fad4694 100644 --- a/app-instance/frontend/components/chat-workbench/ChatWorkbench.tsx +++ b/app-instance/frontend/components/chat-workbench/ChatWorkbench.tsx @@ -3,12 +3,7 @@ import React from 'react'; import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { MessageList } from '@/components/chat-workbench/MessageList'; -import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar'; -import { ProcessLane } from '@/components/chat-workbench/ProcessLane'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; export function ChatWorkbench({ messages, @@ -33,58 +28,10 @@ export function ChatWorkbench({ selectedRunId: string | null; onSelectRun: (runId: string) => void; onCancelRun: (runId: string) => void; - onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void; + onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void; }) { - const { locale } = useAppI18n(); - const [isDesktop, setIsDesktop] = React.useState(() => - typeof window === 'undefined' ? true : window.matchMedia('(min-width: 1024px)').matches - ); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - const mediaQuery = window.matchMedia('(min-width: 1024px)'); - const updateLayout = () => setIsDesktop(mediaQuery.matches); - updateLayout(); - - if (typeof mediaQuery.addEventListener === 'function') { - mediaQuery.addEventListener('change', updateLayout); - return () => mediaQuery.removeEventListener('change', updateLayout); - } - - mediaQuery.addListener(updateLayout); - return () => mediaQuery.removeListener(updateLayout); - }, []); - - const selectedRun = selectedRunId - ? processRuns.find((item) => item.run_id === selectedRunId) || null - : null; - const selectedRunEvents = selectedRun - ? processEvents.filter((item) => item.run_id === selectedRun.run_id) - : []; - const selectedRunArtifacts = selectedRun - ? processArtifacts.filter((item) => item.run_id === selectedRun.run_id) - : []; - const hasResultsPanel = Boolean( - selectedRun && - ( - selectedRun.summary || - selectedRunEvents.length > 0 || - selectedRunArtifacts.length > 0 - ) - ); - const hasProcessPanel = processRuns.length > 0; - const desktopColumns = hasProcessPanel && hasResultsPanel - ? 'grid-cols-[minmax(0,1fr)_340px_360px]' - : hasProcessPanel - ? 'grid-cols-[minmax(0,1fr)_340px]' - : hasResultsPanel - ? 'grid-cols-[minmax(0,1fr)_360px]' - : 'grid-cols-[minmax(0,1fr)]'; - - const messageList = ( + return ( + <div className="h-full"> <MessageList messages={messages} isThinking={isThinking} @@ -93,81 +40,11 @@ export function ChatWorkbench({ processRuns={processRuns} processEvents={processEvents} processArtifacts={processArtifacts} - selectedRunId={selectedRun?.run_id || null} + selectedRunId={selectedRunId} onSelectRun={onSelectRun} onCancelRun={onCancelRun} onFeedback={onFeedback} /> - ); - - if (isDesktop) { - return ( - <div className={`grid h-full ${desktopColumns}`}> - <div className="min-h-0"> - {messageList} - </div> - {hasProcessPanel && ( - <div className="min-h-0"> - <ProcessLane - runs={processRuns} - events={processEvents} - selectedRunId={selectedRun?.run_id || null} - onSelectRun={onSelectRun} - onCancelRun={onCancelRun} - /> - </div> - )} - {hasResultsPanel && ( - <div className="min-h-0"> - <ArtifactSidebar - selectedRun={selectedRun} - events={processEvents} - artifacts={processArtifacts} - /> - </div> - )} - </div> - ); - } - - return ( - <div className="h-full"> - {!hasResultsPanel && !hasProcessPanel ? ( - messageList - ) : ( - <Tabs defaultValue="chat" className="h-full flex flex-col"> - <div className="px-4 pt-3 border-b border-border"> - <TabsList className={`grid w-full ${hasResultsPanel ? 'grid-cols-3' : 'grid-cols-2'}`}> - <TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger> - <TabsTrigger value="process">{pickAppText(locale, '过程', 'Process')}</TabsTrigger> - {hasResultsPanel && ( - <TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</TabsTrigger> - )} - </TabsList> - </div> - <TabsContent value="chat" className="flex-1 min-h-0 mt-0"> - {messageList} - </TabsContent> - <TabsContent value="process" className="flex-1 min-h-0 mt-0"> - <ProcessLane - runs={processRuns} - events={processEvents} - selectedRunId={selectedRun?.run_id || null} - onSelectRun={onSelectRun} - onCancelRun={onCancelRun} - /> - </TabsContent> - {hasResultsPanel && ( - <TabsContent value="results" className="flex-1 min-h-0 mt-0"> - <ArtifactSidebar - selectedRun={selectedRun} - events={processEvents} - artifacts={processArtifacts} - /> - </TabsContent> - )} - </Tabs> - )} </div> ); } diff --git a/app-instance/frontend/components/chat-workbench/MarkdownContent.tsx b/app-instance/frontend/components/chat-workbench/MarkdownContent.tsx index 611c725..a0bcfc9 100644 --- a/app-instance/frontend/components/chat-workbench/MarkdownContent.tsx +++ b/app-instance/frontend/components/chat-workbench/MarkdownContent.tsx @@ -5,34 +5,34 @@ import remarkGfm from 'remark-gfm'; export function MarkdownContent({ content }: { content: string }) { return ( - <div className="prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> + <div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ table: ({ children, ...props }) => ( - <div className="my-3 overflow-x-auto rounded-lg border border-border"> + <div className="my-3 overflow-x-auto rounded-lg border border-[#D8D2CE]"> <table className="w-full border-collapse text-sm" {...props}> {children} </table> </div> ), thead: ({ children, ...props }) => ( - <thead className="bg-muted/60" {...props}> + <thead className="bg-[#ECE8E5]" {...props}> {children} </thead> ), th: ({ children, ...props }) => ( - <th className="px-3 py-2 text-left font-semibold text-foreground border-b border-border" {...props}> + <th className="border-b border-[#D8D2CE] px-3 py-2 text-left font-semibold text-[#0B0B0B]" {...props}> {children} </th> ), td: ({ children, ...props }) => ( - <td className="px-3 py-2 border-b border-border/50" {...props}> + <td className="border-b border-[#E7E2DE] px-3 py-2 text-[#1D1715]" {...props}> {children} </td> ), tr: ({ children, ...props }) => ( - <tr className="hover:bg-muted/30 transition-colors" {...props}> + <tr className="transition-colors hover:bg-[#F7F5F4]" {...props}> {children} </tr> ), diff --git a/app-instance/frontend/components/chat-workbench/MessageList.tsx b/app-instance/frontend/components/chat-workbench/MessageList.tsx index 6c94d7b..c9c47e4 100644 --- a/app-instance/frontend/components/chat-workbench/MessageList.tsx +++ b/app-instance/frontend/components/chat-workbench/MessageList.tsx @@ -1,7 +1,8 @@ 'use client'; import React from 'react'; -import { Bot, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-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 { getAccessToken, getFileUrl } from '@/lib/api'; @@ -44,24 +45,31 @@ function MessageBubble({ }: { message: ChatMessage; canSendFeedback: boolean; - onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void; + onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void; }) { const { locale } = useAppI18n(); const isUser = message.role === 'user'; const textContent = typeof message.content === 'string' ? message.content : String(message.content || ''); + const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | 'revise' | null>(null); + const [feedbackComment, setFeedbackComment] = React.useState(''); + const validationFailed = message.validation_status === 'failed'; + const validationDetails = + validationFailed + ? pickAppText(locale, '详细原因会在任务验证区展示;展开任务可查看验证报告。', 'Detailed reasons are shown in the task validation area. Open the task to inspect the validation report.') + : ''; return ( <div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}> {!isUser && ( - <div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5"> + <div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-[#F1EFEE]"> <Bot className="w-4 h-4 text-primary" /> </div> )} <div - className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${ + className={`max-w-[88%] px-4 py-3 ${ isUser - ? 'bg-primary text-primary-foreground' - : 'bg-card border border-border/80' + ? 'rounded-[28px] bg-primary text-primary-foreground' + : 'rounded-none bg-transparent text-[#1D1715]' }`} > {message.attachments && message.attachments.length > 0 && ( @@ -110,42 +118,107 @@ function MessageBubble({ ) : ( <MarkdownContent content={textContent} /> )} + {!isUser && message.task_id && ( + <div className="mt-3 rounded-md border border-border bg-muted/35 p-3"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="min-w-0"> + <div className="text-xs font-medium uppercase text-muted-foreground">Task</div> + <div className="mt-1 truncate text-sm font-medium"> + {pickAppText(locale, '已创建任务', 'Task created')}: {message.task_id} + </div> + </div> + <Link + href={`/tasks/${encodeURIComponent(message.task_id)}`} + className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90" + > + {pickAppText(locale, '查看任务', 'Open task')} + <ChevronRight className="h-3.5 w-3.5" /> + </Link> + </div> + </div> + )} + {!isUser && validationFailed && ( + <details className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3"> + <summary className="cursor-pointer text-base font-semibold text-destructive"> + {pickAppText(locale, '验证失败', 'Validation failed')} + </summary> + <p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p> + </details> + )} {!isUser && canSendFeedback && message.run_id && ( - <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/70 pt-2"> + <div className="mt-3 space-y-2 border-t border-border/70 pt-3"> {message.feedback_state ? ( - <span className="text-xs text-muted-foreground"> - {message.feedback_state === 'satisfied' - ? pickAppText(locale, '已标记满意', 'Marked satisfied') - : message.feedback_state === 'revise' - ? pickAppText(locale, '已请求修改', 'Revision requested') - : pickAppText(locale, '已放弃任务', 'Task abandoned')} - </span> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <CheckCircle2 className="h-3.5 w-3.5" /> + <span> + {message.feedback_state === 'satisfied' + ? pickAppText(locale, '已标记满意', 'Marked satisfied') + : message.feedback_state === 'revise' + ? pickAppText(locale, '已请求修改', 'Revision requested') + : pickAppText(locale, '已放弃任务', 'Task abandoned')} + </span> + </div> ) : ( <> - <button - type="button" - onClick={() => onFeedback(message.run_id!, 'satisfied')} - className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" - > - <ThumbsUp className="h-3.5 w-3.5" /> - {pickAppText(locale, '满意', 'Satisfied')} - </button> - <button - type="button" - onClick={() => onFeedback(message.run_id!, 'revise')} - className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" - > - <RefreshCcw className="h-3.5 w-3.5" /> - {pickAppText(locale, '需要修改', 'Revise')} - </button> - <button - type="button" - onClick={() => onFeedback(message.run_id!, 'abandon')} - className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" - > - <XCircle className="h-3.5 w-3.5" /> - {pickAppText(locale, '放弃', 'Abandon')} - </button> + <div className="flex flex-wrap items-center gap-2"> + <button + type="button" + onClick={() => setFeedbackMode('satisfied')} + className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" + > + <ThumbsUp className="h-3.5 w-3.5" /> + {pickAppText(locale, '满意', 'Satisfied')} + </button> + <button + type="button" + onClick={() => setFeedbackMode('revise')} + className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" + > + <RefreshCcw className="h-3.5 w-3.5" /> + {pickAppText(locale, '需要修改', 'Revise')} + </button> + <button + type="button" + onClick={() => onFeedback(message.run_id!, 'abandon')} + className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" + > + <XCircle className="h-3.5 w-3.5" /> + {pickAppText(locale, '放弃', 'Abandon')} + </button> + </div> + {feedbackMode && ( + <div className="space-y-2 rounded-md border border-border bg-background p-2"> + <textarea + value={feedbackComment} + onChange={(event) => setFeedbackComment(event.target.value)} + placeholder={ + feedbackMode === 'revise' + ? pickAppText(locale, '写下需要修改的地方...', 'Describe what needs to change...') + : pickAppText(locale, '可选:补充说明...', 'Optional note...') + } + className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring" + /> + <div className="flex justify-end gap-2"> + <button + type="button" + onClick={() => { + setFeedbackMode(null); + setFeedbackComment(''); + }} + className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent" + > + {pickAppText(locale, '取消', 'Cancel')} + </button> + <button + type="button" + onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)} + className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90" + > + {pickAppText(locale, '提交', 'Submit')} + </button> + </div> + </div> + )} </> )} {message.validation_status && message.validation_status !== 'unknown' && ( @@ -162,7 +235,7 @@ function MessageBubble({ )} </div> {isUser && ( - <div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5"> + <div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary"> <User className="w-4 h-4" /> </div> )} @@ -269,7 +342,7 @@ export function MessageList({ selectedRunId: string | null; onSelectRun: (runId: string) => void; onCancelRun: (runId: string) => void; - onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void; + onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void; }) { const { locale } = useAppI18n(); const visibleMessages = React.useMemo( @@ -311,12 +384,12 @@ export function MessageList({ .find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id; return ( - <ScrollArea className="h-full px-4" viewportRef={viewportRef}> - <div className="max-w-6xl mx-auto py-4 space-y-4"> + <ScrollArea className="h-full px-8" viewportRef={viewportRef}> + <div className="mx-auto max-w-5xl space-y-8 py-10"> {visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && ( <div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <Bot className="w-12 h-12 mb-4 opacity-50" /> - <p className="text-lg font-medium">Boardware Agent Sandbox</p> + <p className="text-lg font-medium text-foreground">Beaver</p> <p className="text-sm">{pickAppText(locale, '发送消息开始对话', 'Send a message to start the conversation')}</p> </div> )} diff --git a/app-instance/frontend/components/chat-workbench/ProcessLane.tsx b/app-instance/frontend/components/chat-workbench/ProcessLane.tsx index 2c61d5c..7dcfe84 100644 --- a/app-instance/frontend/components/chat-workbench/ProcessLane.tsx +++ b/app-instance/frontend/components/chat-workbench/ProcessLane.tsx @@ -13,11 +13,11 @@ import { useAppI18n } from '@/lib/i18n/provider'; import { cn } from '@/lib/utils'; function statusTone(status: string) { - if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20'; - if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20'; - if (status === 'cancelled') return 'bg-zinc-500/10 text-zinc-300 border-zinc-500/20'; - if (status === 'waiting') return 'bg-amber-500/10 text-amber-300 border-amber-500/20'; - return 'bg-sky-500/10 text-sky-300 border-sky-500/20'; + if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]'; + if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]'; + if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]'; + if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]'; + return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]'; } function actorIcon(run: ProcessRun) { @@ -147,7 +147,7 @@ export function ProcessLane({ </div> ))} {run.status === 'error' && ( - <div className="flex items-center gap-2 text-xs text-rose-300"> + <div className="flex items-center gap-2 text-xs text-[#5F5550]"> <AlertCircle className="w-3.5 h-3.5" /> {pickAppText(locale, '此任务执行失败。', 'This task failed.')} </div> @@ -168,8 +168,8 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) { const rawEphemeral = metadata?.ephemeral_skill_names; const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : []; const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : []; - const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : ''; - if (selected.length === 0 && ephemeral.length === 0 && !draftId) { + const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : ''; + if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) { return null; } return ( @@ -184,9 +184,9 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) { ephemeral:{name} </Badge> ))} - {draftId && ( + {guidanceId && ( <Badge variant="outline" className="text-[10px]"> - draft:{draftId.slice(0, 8)} + guidance:{guidanceId.slice(0, 8)} </Badge> )} </div> diff --git a/app-instance/frontend/components/office/OfficeShared.tsx b/app-instance/frontend/components/office/OfficeShared.tsx index 2a87fe4..5d036c4 100644 --- a/app-instance/frontend/components/office/OfficeShared.tsx +++ b/app-instance/frontend/components/office/OfficeShared.tsx @@ -20,13 +20,13 @@ export function OfficeStatusBadge({ variant="outline" className={cn( 'border text-[11px]', - status === 'done' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700', - status === 'running' && 'border-sky-500/30 bg-sky-500/10 text-sky-700', - status === 'waiting' && 'border-amber-500/30 bg-amber-500/10 text-amber-700', - status === 'blocked' && 'border-orange-500/30 bg-orange-500/10 text-orange-700', - status === 'queued' && 'border-slate-500/30 bg-slate-500/10 text-slate-700', - status === 'error' && 'border-rose-500/30 bg-rose-500/10 text-rose-700', - status === 'cancelled' && 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700', + status === 'done' && 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]', + status === 'running' && 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]', + status === 'waiting' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]', + status === 'blocked' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]', + status === 'queued' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]', + status === 'error' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]', + status === 'cancelled' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]', className )} > @@ -70,10 +70,10 @@ export function zonePanelClassName(zone: OfficeZoneView): string { return cn( 'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm', 'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]', - zone.tone === 'info' && 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.95),rgba(224,242,254,0.7))]', - zone.tone === 'warn' && 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.95),rgba(254,243,199,0.72))]', - zone.tone === 'danger' && 'border-rose-200 bg-[linear-gradient(180deg,rgba(255,241,242,0.96),rgba(255,228,230,0.76))]', - zone.tone === 'success' && 'border-emerald-200 bg-[linear-gradient(180deg,rgba(236,253,245,0.96),rgba(209,250,229,0.74))]', + zone.tone === 'info' && 'border-[#BCC4CE] bg-[#E4E7EB]/70', + zone.tone === 'warn' && 'border-[#B8AEA8] bg-[#E7E2DE]/70', + zone.tone === 'danger' && 'border-[#B8AEA8] bg-[#E7E2DE]/80', + zone.tone === 'success' && 'border-[#B7C2B5] bg-[#E3E8E2]/75', zone.tone === 'neutral' && 'border-border bg-card' ); } diff --git a/app-instance/frontend/components/task-management/TaskManagementTabs.tsx b/app-instance/frontend/components/task-management/TaskManagementTabs.tsx index 3974a61..fea28e5 100644 --- a/app-instance/frontend/components/task-management/TaskManagementTabs.tsx +++ b/app-instance/frontend/components/task-management/TaskManagementTabs.tsx @@ -1,8 +1,8 @@ 'use client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { Building2, Clock3 } from 'lucide-react'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { Clock3, ListTodo } from 'lucide-react'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; @@ -10,28 +10,30 @@ import { cn } from '@/lib/utils'; const TASK_MANAGEMENT_TABS = [ { - label: 'Office', - href: '/office', - icon: Building2, - match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'), + label: 'ordinary', + href: '/tasks', + icon: ListTodo, + match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab !== 'scheduled', }, { - label: 'Scheduled tasks', - href: '/cron', + label: 'scheduled', + href: '/tasks?tab=scheduled', icon: Clock3, - match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'), + match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab === 'scheduled', }, ] as const; export function TaskManagementTabs() { const { locale } = useAppI18n(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const activeTab = searchParams.get('tab'); return ( <div className="rounded-2xl border border-border/70 bg-muted/20 p-1"> <div className="flex flex-wrap gap-1"> {TASK_MANAGEMENT_TABS.map((tab) => { - const isActive = tab.match(pathname); + const isActive = tab.match(pathname, activeTab); const Icon = tab.icon; return ( @@ -46,9 +48,9 @@ export function TaskManagementTabs() { )} > <Icon className="h-4 w-4" /> - {tab.href === '/cron' + {tab.label === 'scheduled' ? pickAppText(locale, '定时任务', 'Scheduled tasks') - : pickAppText(locale, '办公室', 'Office')} + : pickAppText(locale, '普通任务', 'Ordinary tasks')} </Link> ); })} diff --git a/app-instance/frontend/components/ui/button.tsx b/app-instance/frontend/components/ui/button.tsx index 81e2e6e..e04e46a 100644 --- a/app-instance/frontend/components/ui/button.tsx +++ b/app-instance/frontend/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)] ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { @@ -13,7 +13,7 @@ const buttonVariants = cva( destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: - 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + 'border border-transparent bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', diff --git a/app-instance/frontend/components/ui/card.tsx b/app-instance/frontend/components/ui/card.tsx index fca7be4..9815634 100644 --- a/app-instance/frontend/components/ui/card.tsx +++ b/app-instance/frontend/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef< <div ref={ref} className={cn( - 'rounded-lg border bg-card text-card-foreground shadow-sm', + 'rounded-2xl border border-black/[0.04] bg-card/70 text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)]', className )} {...props} diff --git a/app-instance/frontend/components/ui/input.tsx b/app-instance/frontend/components/ui/input.tsx index c982112..66d607e 100644 --- a/app-instance/frontend/components/ui/input.tsx +++ b/app-instance/frontend/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( <input type={type} className={cn( - 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + 'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50', className )} ref={ref} diff --git a/app-instance/frontend/components/ui/textarea.tsx b/app-instance/frontend/components/ui/textarea.tsx index 4ca0611..ece78c1 100644 --- a/app-instance/frontend/components/ui/textarea.tsx +++ b/app-instance/frontend/components/ui/textarea.tsx @@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( return ( <textarea className={cn( - 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + 'flex min-h-[80px] w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50', className )} ref={ref} diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index 38708a1..a8db246 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -6,11 +6,16 @@ import type { AuthzRegisterBackendResponse, AuthzStatus, AuthUser, + ActiveTask, + ChatLogsResponse, + BackendTask, ChatMessage, CronJob, FileAttachment, Marketplace, MarketplacePlugin, + NotificationDetail, + NotificationRun, PluginInfo, ProviderConfigPayload, Session, @@ -19,6 +24,10 @@ import type { SkillDraft, SkillDraftEvalReport, SkillDraftSafetyReport, + SkillHubInstallResponse, + SkillHubSearchItem, + SkillHubSearchResponse, + SkillHubVersionResponse, SkillLearningCandidate, SkillReviewRecord, SlashCommand, @@ -252,7 +261,12 @@ export async function getMe(): Promise<AuthUser> { export async function sendMessage( message: string, sessionId: string = 'web:default', - attachments?: FileAttachment[] + attachments?: FileAttachment[], + options?: { + replyToScheduledRunId?: string; + scheduledReplyIntent?: 'revise_once' | 'update_future' | 'continue_task'; + thinkingEnabled?: boolean; + } ): Promise<{ response?: string; status?: string; @@ -266,6 +280,13 @@ export async function sendMessage( if (attachments && attachments.length > 0) { body.attachments = attachments; } + if (options?.replyToScheduledRunId) { + body.reply_to_scheduled_run_id = options.replyToScheduledRunId; + body.scheduled_reply_intent = options.scheduledReplyIntent || 'revise_once'; + } + if (typeof options?.thinkingEnabled === 'boolean') { + body.thinking_enabled = options.thinkingEnabled; + } const result = await fetchJSON<{ response?: string; status?: string; @@ -583,8 +604,14 @@ export async function getSessionProcess(key: string): Promise<SessionProcessProj return fetchJSON(`/api/sessions/${encodeURIComponent(key)}/process`); } -export async function deleteSession(key: string): Promise<void> { - await fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'DELETE' }); +export async function getChatLogs(limit = 50): Promise<ChatLogsResponse> { + return fetchJSON(`/api/debug/chat-logs?limit=${encodeURIComponent(String(limit))}`, { + timeoutMs: 30000, + }); +} + +export async function archiveSession(key: string): Promise<void> { + await fetchJSON(`/api/sessions/${encodeURIComponent(key)}/archive`, { method: 'POST' }); } // --------------------------------------------------------------------------- @@ -629,7 +656,10 @@ export async function addCronJob(params: { every_seconds?: number; cron_expr?: string; at_iso?: string; + tz?: string; session_key?: string; + mode?: 'notification' | 'task'; + requires_followup?: boolean; }): Promise<CronJob> { return fetchJSON('/api/cron/jobs', { method: 'POST', @@ -652,6 +682,40 @@ export async function runCronJob(jobId: string): Promise<void> { await fetchJSON(`/api/cron/jobs/${jobId}/run`, { method: 'POST' }); } +export async function listNotifications(): Promise<NotificationRun[]> { + return fetchJSON('/api/notifications'); +} + +export async function getNotification(scheduledRunId: string): Promise<NotificationDetail> { + return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}`); +} + +export async function engageNotification( + scheduledRunId: string, + intent: 'revise_once' | 'update_future' | 'continue_task' +): Promise<{ ok: boolean; task_id: string; intent: string }> { + return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}/engage`, { + method: 'POST', + body: JSON.stringify({ intent }), + }); +} + +export async function listBackendTasks(): Promise<BackendTask[]> { + return fetchJSON('/api/tasks'); +} + +export async function getBackendTask(taskId: string): Promise<BackendTask> { + return fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`); +} + +export async function deleteBackendTask(taskId: string): Promise<void> { + await fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE' }); +} + +export async function getActiveTask(sessionId: string): Promise<ActiveTask | null> { + return fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}/active-task`); +} + export async function ping(): Promise<{ message: string }> { return fetchJSON('/api/ping'); } @@ -877,6 +941,12 @@ export async function cancelDelegation(runId: string): Promise<{ ok: boolean; ru }); } +export async function retryDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> { + return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/retry`, { + method: 'POST', + }); +} + export async function listMcpServers(): Promise<UiMcpServerDescriptor[]> { return fetchJSON('/api/mcp/servers'); } @@ -1190,6 +1260,62 @@ export async function uploadSkill(file: File): Promise<Skill> { return res.json(); } +export async function migrateSkills(): Promise<{ included: Array<Record<string, unknown>>; skipped: Array<Record<string, unknown>> }> { + return fetchJSON('/api/skills/migrate', { method: 'POST', timeoutMs: 45000 }); +} + +// --------------------------------------------------------------------------- +// SkillHub marketplace +// --------------------------------------------------------------------------- + +export async function searchSkillHubSkills(params: { + q?: string; + sort?: 'relevance' | 'downloads' | 'newest'; + page?: number; + size?: number; + namespace?: string; +} = {}): Promise<SkillHubSearchResponse> { + const search = new URLSearchParams(); + if (params.q) search.set('q', params.q); + if (params.sort) search.set('sort', params.sort); + if (typeof params.page === 'number') search.set('page', String(params.page)); + if (typeof params.size === 'number') search.set('size', String(params.size)); + if (params.namespace) search.set('namespace', params.namespace); + const suffix = search.toString(); + return fetchJSON(`/api/marketplaces/skills/search${suffix ? `?${suffix}` : ''}`); +} + +export async function getSkillHubDetail(namespace: string, slug: string): Promise<SkillHubSearchItem> { + return fetchJSON( + `/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}` + ); +} + +export async function getSkillHubVersion( + namespace: string, + slug: string, + version: string +): Promise<SkillHubVersionResponse> { + return fetchJSON( + `/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}` + ); +} + +export async function installSkillHubSkill( + namespace: string, + slug: string, + version?: string +): Promise<SkillHubInstallResponse> { + return fetchJSON( + `/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/install`, + { + method: 'POST', + body: JSON.stringify({ version }), + timeoutMs: 45000, + } + ); +} + // --------------------------------------------------------------------------- // Marketplace (proxied) // --------------------------------------------------------------------------- diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index 60e6676..87a6719 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -51,6 +51,10 @@ export interface ChatMessage { validation_status?: 'passed' | 'failed' | 'unknown'; feedback_state?: 'satisfied' | 'revise' | 'abandon'; feedback_error?: string; + message_type?: string | null; + scheduled_job_id?: string | null; + scheduled_run_id?: string | null; + cron_job_name?: string | null; } export interface Session { @@ -67,6 +71,52 @@ export interface SessionDetail { updated_at: string; } +export interface ChatLogEvent { + message_id?: number | null; + run_id?: string | null; + role: string; + event_type?: string | null; + content?: string | null; + timestamp?: string; + context_visible?: boolean; + tool_name?: string | null; + tool_call_id?: string | null; + tool_calls?: Array<Record<string, unknown>> | null; + finish_reason?: string | null; + reasoning?: string | null; + reasoning_details?: unknown; + codex_reasoning_items?: unknown; + event_payload?: Record<string, unknown> | null; +} + +export interface ChatLogRun { + run_id: string; + session_id: string; + title?: string | null; + source?: string | null; + task_id?: string | null; + attempt_index?: number | null; + task_mode?: boolean | null; + user_input?: string; + started_at?: string; + ended_at?: string | null; + finish_reason?: string | null; + events: ChatLogEvent[]; +} + +export interface ChatLogSession { + session_id: string; + source?: string | null; + title?: string | null; + created_at?: string; + updated_at?: string; + runs: ChatLogRun[]; +} + +export interface ChatLogsResponse { + sessions: ChatLogSession[]; +} + export interface ProviderStatus { id?: string; name: string; @@ -168,14 +218,125 @@ export interface CronJob { schedule_expr: string | null; schedule_every_ms: number | null; message: string; + mode?: 'notification' | 'task'; + requires_followup?: boolean; deliver: boolean; channel: string | null; to: string | null; + session_key?: string | null; next_run_at_ms: number | null; last_run_at_ms: number | null; last_status: string | null; last_error: string | null; + last_scheduled_run_id?: string | null; + last_task_id?: string | null; + last_run_id?: string | null; + history?: Array<{ + started_at_ms: number; + finished_at_ms?: number | null; + status: string; + mode?: 'notification' | 'task'; + notification_session_id?: string | null; + output?: string | null; + task_id?: string | null; + run_id?: string | null; + error?: string | null; + scheduled_run_id?: string; + engaged?: boolean; + engage_intent?: string | null; + }>; created_at_ms: number; + updated_at_ms?: number; +} + +export interface NotificationRun { + scheduled_run_id: string; + job_id: string; + job_name: string; + title: string; + message: string; + status: string; + mode: 'notification' | 'task'; + started_at_ms: number; + finished_at_ms?: number | null; + started_at?: string | null; + finished_at?: string | null; + output?: string | null; + error?: string | null; + notification_session_id: string; + task_id?: string | null; + run_id?: string | null; + engaged?: boolean; + engage_intent?: string | null; +} + +export interface NotificationDetail extends NotificationRun { + detail: SessionDetail; +} + +export interface BackendTaskEvent { + event_id: string; + task_id: string; + session_id: string; + run_id?: string | null; + event_type: string; + created_at: string; + payload: Record<string, unknown>; +} + +export interface BackendTaskRunMessage { + role: 'user' | 'assistant' | 'tool'; + content: string; + created_at?: string; + tool_name?: string | null; +} + +export interface BackendTaskRun { + run_id: string; + title: string; + session_id: string; + started_at?: string | null; + ended_at?: string | null; + success?: boolean | null; + finish_reason?: string | null; + attempt_index?: number | null; + task_text?: string; + messages: BackendTaskRunMessage[]; + validation_result?: Record<string, unknown> | null; +} + +export interface BackendTask { + task_id: string; + session_id: string; + parent_task_id?: string | null; + description: string; + short_title?: string | null; + is_open?: boolean; + goal: string; + constraints: string[]; + priority: number; + status: string; + creator: string; + created_at: string; + updated_at: string; + closed_at?: string | null; + close_reason?: string | null; + satisfaction?: number | null; + run_ids: string[]; + skill_names: string[]; + feedback: Array<Record<string, unknown>>; + validation_result?: Record<string, unknown> | null; + metadata: Record<string, unknown>; + events?: BackendTaskEvent[]; + runs?: BackendTaskRun[]; +} + +export interface ActiveTask { + task_id: string; + status: string; + short_title: string; + description: string; + updated_at: string; } export interface Marketplace { @@ -191,6 +352,75 @@ export interface MarketplacePlugin { installed: boolean; } +export interface SkillHubVersionRef { + id?: number; + version: string; + status?: string; + createdAt?: string; + publishedAt?: string | null; +} + +export interface SkillHubSearchItem { + id?: number; + slug: string; + displayName: string; + summary: string; + namespace: string; + downloadCount: number; + starCount: number; + ratingAvg?: number; + ratingCount?: number; + headlineVersion?: SkillHubVersionRef | null; + publishedVersion?: SkillHubVersionRef | null; + installed?: boolean; + installed_version?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface SkillHubSearchResponse { + items: SkillHubSearchItem[]; + total: number; + page: number; + size: number; +} + +export interface SkillHubFileInfo { + id?: number; + filePath: string; + fileSize: number; + contentType?: string | null; + sha256?: string | null; +} + +export interface SkillHubVersionDetail { + id?: number; + version?: string; + status?: string; + fileCount?: number; + totalSize?: number; + parsedMetadataJson?: string | null; + manifestJson?: string | null; + createdAt?: string; + publishedAt?: string | null; +} + +export interface SkillHubVersionResponse { + detail: SkillHubVersionDetail; + files: SkillHubFileInfo[]; +} + +export interface SkillHubInstallResponse { + ok: boolean; + skill_name: string; + version: string; + source: 'skillhub'; + namespace: string; + slug: string; + installed_path: string; + already_installed?: boolean; +} + export type ProcessActorType = 'agent' | 'mcp' | 'system'; export type ProcessRunStatus = | 'queued' @@ -251,6 +481,10 @@ export interface UiMcpServerDescriptor { id: string; name: string; transport: 'stdio' | 'http'; + kind?: 'local' | 'online'; + category?: string; + managed?: boolean; + source?: string; url?: string | null; command?: string | null; args?: string[]; diff --git a/app-instance/frontend/前端需求讨论.md b/app-instance/frontend/前端需求讨论.md new file mode 100644 index 0000000..9d22ac5 --- /dev/null +++ b/app-instance/frontend/前端需求讨论.md @@ -0,0 +1,968 @@ +# 前端需求讨论 + +当前讨论到第二页:**Task 管理界面**。 + +这份文档先不展开其它页面。每次只把一页讲清楚:页面定位、用户目标、必须展示什么、不要塞什么、争议点是什么。等这一页定下来,再开下一页。 + +相关参考: + +- 后端运行结构:`../backend/flow.md` +- 后端施工状态:`../backend/施工指南.md` +- 长期蓝图:`../backend/change.md` +- 旧系统蓝图:`../backend-old/change.md` + +--- + +## 第 1 页:对话页 + +路径:`/` + +一句话定位: + +> 对话页是用户和主 Agent 交互的主工作台。它负责提交问题/任务、展示最终回答、展示必要的运行状态,并收集用户对 Task 结果的反馈。 + +它承担一部分轻量后台管理工作,主要是会话管理和任务入口。但它不应该变成完整后台管理台,也不应该让用户直接配置 team strategy、sub-agent、MCP、插件市场等系统能力。 + +已确认的方向: + +1. session 不做“删除”,改成“归档”。 + - 归档后的 session 不再出现在前端会话列表。 + - 归档不等于抹除记忆;相关记忆和历史数据仍然存在。 +2. 附件是对话页第一版必须能力。 +3. Slash command 不保留。 +4. 复杂任务创建后,对话中显示 Task 创建回复,点击跳转到 Task 管理界面。 +5. 原 `Office` 概念改名为 `Task`,并删除图形化办公室展示,改为更合理的任务交互过程与链条展示。 +6. “需要修改”走类似 Codex plan mode 的选择/评论框;用户也可以继续在主聊天框说话,此时默认上一轮结果满意。 +7. 验证失败时,大字显示状态,详细原因小字展示并默认折叠,可展开查看。 + +--- + +## 1. 用户打开对话页是为了什么 + +| 用户目标 | 页面需要支持 | +|---|---| +| 问一个简单问题 | 快速输入、快速看到 assistant 回答,不制造 Task 负担 | +| 提交一个复杂任务 | 输入任务后进入运行中状态,最终看到主 Agent synthesis 的结果 | +| 知道系统是不是还在工作 | 明确显示 thinking/running/失败状态 | +| 看到必要过程 | 能看到规划、子任务、验证、重试等摘要,但不被原始事件淹没 | +| 判断结果是否可接受 | 最新 Task 结果下提供“满意 / 需要修改 / 放弃” | +| 继续上下文 | 会话列表、切换历史会话、加载历史消息 | +| 修改结果 | 反馈“需要修改”后,下一条消息应自然复用未关闭 Task | +| 管理会话可见性 | 支持归档 session,使它不再出现在前端列表 | +| 进入复杂任务工作台 | Task 创建后,从对话消息中的 Task 链接跳转到 Task 管理界面 | + +--- + +## 2. 当前对话页已有元素 + +| 元素 | 当前情况 | 本页讨论点 | +|---|---|---| +| 会话列表 | 左侧已有新建、切换、删除会话 | 删除应改为归档;归档后前端不再显示 | +| 消息流 | 中间展示 user / assistant 消息 | 是否需要显示模型、run_id、token、工具次数等元信息 | +| 输入框 | 支持文本输入、回车发送 | 是否需要模型选择、参数选择、模式选择 | +| 运行状态 | 有 thinking 状态 | 是否要区分 simple run、Task run、验证中、重试中 | +| 反馈按钮 | 最新 assistant Task 结果显示三按钮 | 需要改成带评论框的选择交互 | +| 过程区 | 桌面端有 ProcessLane,移动端有 Process tab | 对话页不再常驻大过程区;复杂任务通过 Task 链接进入 Task 管理界面 | +| 结果区 | 有 ArtifactSidebar | 需要和 Task 管理界面重新划边界 | +| Office 入口 | 当前任务现场 banner | 不应该在顶部常驻;改成消息中的 Task 链接 | +| 附件入口 | 前端已有上传入口 | 第一版必须保留,需要后端补足附件语义 | +| Slash command | 前端已有 `/` 命令选择 | 不保留,应移除 | + +--- + +## 3. 对话页必须有 + +### 3.1 会话区 + +必须支持: + +1. 新建会话。 +2. 切换会话。 +3. 归档会话。 +4. 显示会话最近更新时间或可读标题。 +5. 默认不显示已归档会话。 + +暂不确定: + +1. 是否需要会话搜索。 +2. 是否需要手动重命名。 +3. 是否需要 pin。 +4. 是否需要“查看已归档会话”的二级入口。 + +已确认: + +1. 不使用“删除”语义。 +2. 归档只影响前端列表可见性,不等于删除记忆或永久清除历史。 + +### 3.2 消息区 + +必须支持: + +1. 展示用户消息。 +2. 展示 assistant 最终回答。 +3. 刷新后保留 `run_id / task_id / task_status / validation_status / feedback_state` 对应的 UI 状态。 +4. 对 Markdown 内容做稳定渲染。 + +不应该做: + +1. 不直接展示隐藏事件原始 JSON。 +2. 不把 sub-agent 的中间 summary 当成最终回答。 +3. 不让用户手动改内部 Task 状态。 + +### 3.3 输入区 + +必须支持: + +1. 输入文本。 +2. 发送。 +3. 发送失败时明确提示。 +4. 运行中避免重复提交,或明确支持排队。 +5. 上传附件。 +6. 在消息中展示用户已提交的附件。 + +暂不确定: + +1. 附件进入模型上下文的具体语义:作为文件引用、文本提取、图片输入,还是先作为 workspace 文件供工具读取。 +2. 是否要提供模型/provider 快捷选择。 + +不保留: + +1. Slash command。 + +### 3.4 运行状态 + +必须支持: + +1. 等待模型时显示“思考中”。 +2. Task 验证中有明确状态。 +3. 验证失败重试时有明确状态。 +4. 失败时告诉用户失败发生在发送、运行、验证还是反馈。 +5. 验证失败时,大字显示失败状态。 +6. 验证详细原因放在状态下方,以小字/展开区展示,默认折叠。 + +当前缺口: + +1. 现在基本只有 thinking。 +2. 验证中、重试中、Task awaiting feedback 没有足够清晰的产品化表达。 + +### 3.5 反馈区 + +必须支持: + +1. 只对最新 assistant Task 结果显示反馈。 +2. 三种反馈:满意、需要修改、放弃。 +3. 已反馈后显示反馈状态。 +4. 反馈失败时显示错误。 +5. “满意 / 需要修改”使用带评论框的选择交互。 +6. 用户如果不点反馈、直接在主聊天框继续聊天,则默认上一轮结果满意。 + +待继续细化: + +1. “满意”的评论框是可选还是默认隐藏。 +2. “需要修改”的评论是否必填。 +3. “放弃”是否需要二次确认。 +4. “满意”后是否显示“已用于学习候选”之类的反馈。 + +### 3.6 Task 入口 + +必须支持: + +1. 复杂任务创建后,对话流中出现一条 Task 创建回复。 +2. Task 创建回复包含可读标题或 description。 +3. 用户点击 Task 链接后跳转到 Task 管理界面。 +4. 对话页只保留轻量 Task 状态,不展开完整执行链条。 + +示例表达: + +```text +已创建 Task:整理最近三个月销售数据并生成分析结论 +查看 Task +``` + +不应该做: + +1. 不展示未实现的策略按钮,例如 `moa / hierarchy / group_chat`。 +2. 不让用户选择 specialist agent 来影响当前 Task。 +3. 不把完整过程链条塞在对话页主界面。 +4. 不再使用 `Office` 作为用户可见概念。 + +--- + +## 4. 对话页不承担的事情 + +| 不承担 | 原因 | 应该去哪里 | +|---|---|---| +| Skill 审核、发布、回滚 | 这是能力生命周期管理,不是对话主流程 | Skills 页 | +| Provider 深度配置 | 配置属于系统设置,不应打断对话 | Status/Settings | +| MCP server 管理 | 属于工具/集成管理 | MCP/Settings,是否保留待后续讨论 | +| Outlook 浏览与连接 | 属于集成管理 | Outlook/Settings,是否保留待后续讨论 | +| Plugin/Marketplace 管理 | 属于平台扩展 | Plugins/Marketplace,是否保留待后续讨论 | +| 内部 Task 管理 | Task 是运行容器,不是当前产品级实体 | 仅通过过程投影展示 | +| Team strategy 配置 | 当前 team 是 Task 内部执行策略 | 仅展示,不手动配置 | + +对话页承担的轻量后台管理: + +| 承担 | 说明 | +|---|---| +| session 归档 | 替代删除;归档后不在前端会话列表展示,但记忆仍然存在 | +| Task 入口 | 复杂任务创建后,通过对话消息提供跳转到 Task 管理界面的入口 | + +--- + +## 5. 对话页和后端的最小契约 + +| 能力 | 后端接口/数据 | 对话页使用方式 | +|---|---|---| +| 发送消息 | `POST /api/chat` | 输入框发送;返回 final answer + run/task/validation 元数据 | +| WebSocket 对话 | `WS /ws/{session_id}` | 实时发送和接收 assistant message / session_updated | +| 提交反馈 | `POST /api/chat/feedback` | 最新 Task answer 下三按钮 | +| 读取会话 | `GET /api/sessions/{session_id}` | 刷新消息流和反馈状态 | +| 会话列表 | `GET /api/sessions` | 左侧会话列表 | +| 过程投影 | `GET /api/sessions/{session_id}/process` | 右侧过程区,不直接展示隐藏事件 JSON | +| 归档会话 | 待补:archive session API | 归档后会话不再出现在默认列表 | +| 附件 | 待补:chat attachment / file reference 契约 | 对话页必须支持附件上传和附件展示 | +| Task 链接 | 待补:Task 管理页路由与 Task identifier 映射 | 复杂任务创建回复跳转到 Task 管理界面 | + +--- + +## 6. 当前主要争议点 + +| 争议点 | 方案 A | 方案 B | 需要定什么 | +|---|---|---|---| +| 附件 | 当前版本保留,要求后端补附件语义 | 当前版本隐藏,等文件能力页讨论后再接回 | 已定:保留 | +| Slash command | 保留为高级用户快捷入口 | 隐藏,避免旧系统命令残留干扰 | 已定:不保留 | +| 过程区默认状态 | 桌面端默认显示 | 复杂 Task 出现后,以 Task 链接跳转到 Task 管理界面 | 已定:对话页不常驻完整过程区 | +| Office banner | 对话页顶部显示当前任务现场 | 从对话页移除,复杂任务创建后在消息里显示 Task 链接 | 已定:移除顶部 Office banner,Office 改名 Task | +| 模型/provider 选择 | 在输入区提供轻量选择 | 只在设置页改默认模型 | 用户是否经常需要按消息切模型 | +| 反馈评论 | “满意 / 需要修改”弹出选择评论框 | 只点按钮,用户下一条消息补充 | 已定:需要评论框;用户继续主聊天则默认满意 | +| 验证细节 | 大字状态 + 折叠详情 | 只显示通过/未通过 | 已定:状态大字,原因默认折叠 | +| 调试元信息 | 可展开显示 run_id、task_id、token、工具次数 | 普通用户隐藏 | 当前产品面向开发者还是普通使用者 | + +--- + +## 7. 当前版本建议稿 + +这是讨论稿,不是最终结论。 + +对话页当前版本收敛成: + +1. 左侧:会话列表。 + - 删除改为归档。 + - 归档后不在默认列表展示。 +2. 中间:消息流 + 输入框。 +3. 输入区保留附件能力。 +4. 移除 Slash command。 +5. 复杂任务创建后,在对话流里显示 Task 创建回复和跳转链接。 +6. 移除顶部 Office banner。 +7. `Office` 改名为 `Task`;完整任务过程进入 Task 管理界面展示。 +8. 最新 Task 回答下:满意 / 需要修改 / 放弃。 +9. “满意 / 需要修改”使用类似 Codex plan mode 的选择评论框。 +10. 用户不点反馈、直接继续聊天时,默认上一轮结果满意。 +11. 验证失败:大字状态,详细原因折叠展示。 +12. 对话页不提供 Team 策略选择、Sub-agent 选择、Skill 审核、MCP/插件/Outlook 管理。 + +--- + +## 8. 已确认问题 + +| 问题 | 结论 | +|---|---| +| 对话页是不是只作为“主工作台”,不承担后台管理? | 承担部分轻量后台管理,尤其是 session 归档和 Task 入口 | +| 附件是不是对话页第一版必须能力? | 是,必须保留 | +| Slash command 是否继续保留? | 不保留 | +| 过程区是默认显示,还是复杂任务出现后再显示? | 不常驻完整过程区;复杂任务创建后显示 Task 链接,点击进入 Task 管理界面 | +| Office 入口是否应该出现在对话页顶部? | 不应该;Office 改名 Task,入口放在对话消息中 | +| “需要修改”是否需要评论框? | 需要;满意/需要修改使用选择评论框,用户继续主聊天则默认满意 | +| 验证失败时,用户需要看到详细原因还是只看状态? | 大字状态 + 默认折叠的小字详细原因 | + +## 9. 下一页遗留给 Task 管理界面的问题 + +这些问题不在对话页继续展开,留到下一页“Task 管理界面”讨论: + +1. 原 Office 页改名 Task 后,路由叫 `/tasks` 还是继续兼容 `/office`。 +2. Task 管理界面如何展示任务交互过程和链条。 +3. 是否支持暂停、取消、重试某个 Task 或某个节点。 +4. 子任务、验证、技能选择、工具调用、产物如何分层展示。 +5. 删除图形化办公室后,新的 Task 页面信息架构怎么排。 + +--- + +## 第 2 页:Task 管理界面 + +建议路径: + +- 任务管理入口:`/tasks` +- 普通任务详情:`/tasks/{task_id}` +- 定时任务详情/编辑:`/tasks/scheduled/{job_id}` 或 `/tasks/scheduled` + +历史对应: + +- 当前前端里的 `/office` 和 `/office/[taskId]` +- 当前前端里的 `/cron` +- 后续用户可见名称统一改为 `Task` +- 原图形化办公室展示删除,不再把任务现场做成地图/办公室/角色走动形式 +- 当前“任务管理”思路保留:任务分为普通任务和定时任务 +- 旧 `/office` 跳转到 `/tasks` +- 当前 `/cron` 能力并入“定时任务”tab,不再作为顶层导航 + +一句话定位: + +> Task 管理界面是任务中心。它统一管理普通任务和定时任务:普通任务用于查看复杂任务的执行链条和反馈状态;定时任务用于创建、启停和查看计划触发的任务。 + +它不是单个 Task 的详情页本身。单个普通 Task 的链条页是它下面的详情页。 + +它也不是普通聊天页,不是 agent/sub-agent 配置页,不是完整后台设置页。 + +已确认的方向: + +1. 路由改为 `/tasks`,旧 `/office` 跳转到 `/tasks`。 +2. 任务管理入口分为“普通任务 / 定时任务”两个 tab。 +3. 当前 `/cron` 能力并入“定时任务”tab,不再作为顶层导航。 +4. 普通任务列表需要存在,用户可以从列表进入普通任务详情。 +5. 普通任务详情采用“阶段链 + 节点分组”展示执行链条。 +6. 普通任务详情允许直接输入修订意见。 +7. 用户可以暂停、取消、重试普通 Task 或节点。 +8. 第二次验证失败后,普通任务用户可见状态叫“任务失败”。 +9. sub-agent 输出只算节点结果,不算产物。 +10. 产物需要下载按钮,支持单个下载或全部下载。 +11. 已归档 session 里的普通任务仍然在普通任务列表显示。 +12. 已放弃普通任务不允许恢复。 +13. 所有任务没有归档概念;定时任务可以暂时关闭,也可以删除。 +14. 定时任务每次触发都创建普通 Task,并在运行历史里链接过去。 + +--- + +## 1. 用户打开任务管理页是为了什么 + +| 用户目标 | 页面需要支持 | +|---|---| +| 查看普通任务 | 显示从对话中创建的复杂任务列表 | +| 查看定时任务 | 显示计划触发的任务列表 | +| 区分任务类型 | 普通任务和定时任务在同一个任务管理入口中分 tab 或分区展示 | +| 进入普通任务详情 | 点击普通任务进入执行链条页 | +| 管理定时任务 | 创建、启停、编辑、删除、手动运行定时任务 | +| 看任务状态 | 对普通任务显示运行中、等待反馈、需要修改、已完成、已放弃、任务失败等状态 | +| 看计划状态 | 对定时任务显示启用状态、计划规则、上次运行、下次运行、最近结果 | +| 回到对话继续沟通 | 普通任务详情提供来源会话链接 | +| 回看历史任务 | 普通任务列表也显示已归档 session 里的任务 | + +--- + +## 2. 任务类型 + +Task 管理界面至少分两类: + +| 类型 | 来源 | 用户主要动作 | 详情页重点 | +|---|---|---|---| +| 普通任务 | 用户在对话中提交复杂任务后自动创建 | 查看、反馈、回到对话修订 | 执行链条、节点、验证、最终结果 | +| 定时任务 | 用户手动创建计划任务 | 新建、启停、编辑、手动运行、查看历史触发 | 计划规则、触发记录、每次运行结果 | + +普通任务详情页负责展示完整过程: + +```text +Task 创建 +│ +├─ 规划 plan +│ ├─ single +│ └─ team +│ ├─ sequence +│ ├─ parallel +│ └─ dag +│ +├─ 子任务执行 nodes +│ ├─ selected skills +│ ├─ generated ephemeral skill +│ ├─ tool / file / memory activity +│ └─ node result +│ +├─ 主 Agent synthesis +│ +├─ validation +│ ├─ passed +│ ├─ failed +│ └─ retry +│ +└─ feedback + ├─ satisfied + ├─ revise + └─ abandon +``` + +定时任务详情页负责展示计划和触发历史: + +```text +定时任务 +│ +├─ 计划规则 +│ ├─ at +│ ├─ every +│ └─ cron +│ +├─ 目标会话 / message +│ +├─ 触发历史 +│ ├─ run 1 +│ ├─ run 2 +│ └─ run N +│ +└─ 最近结果 / 错误 +``` + +--- + +## 3. 页面结构建议 + +### 3.1 任务管理入口 + +路径建议:`/tasks` + +| 区域 | 内容 | +|---|---| +| 顶部 tabs | 普通任务、定时任务 | +| 普通任务 tab | 复杂任务列表、状态筛选、打开详情、回到对话 | +| 定时任务 tab | 计划任务列表、新建任务、启停、编辑、手动运行 | +| 全局空状态 | 引导用户回到对话页创建普通任务,或创建一个定时任务 | + +待讨论: + +1. 入口是否叫 `任务`,英文是否叫 `Tasks`。 +2. tabs 名称是“普通任务 / 定时任务”,还是“任务 / 计划任务”。 +3. 是否需要全局搜索。 + +### 3.2 普通任务列表 + +| 区域 | 内容 | +|---|---| +| 状态筛选 | 全部、运行中、等待反馈、需要修改、已完成、已放弃、失败 | +| 任务卡片/表格 | 标题、description、来源会话、状态、当前阶段、更新时间、子任务数量、失败数 | +| 快速动作 | 打开详情、回到对话、暂停、取消、重试 | +| 空状态 | 引导用户回到对话页创建复杂任务 | + +普通任务列表不负责展示完整链条,只负责让用户找到任务并进入详情。 + +已确认: + +1. 普通任务列表需要存在。 +2. 已归档 session 里的普通任务仍然显示。 +3. 普通任务没有归档概念。 +4. 已放弃普通任务不允许恢复。 + +### 3.3 定时任务列表 + +| 区域 | 内容 | +|---|---| +| 顶部动作 | 新建定时任务 | +| 列表字段 | 名称、启用状态、计划类型、计划表达式、目标会话、消息摘要、上次运行、下次运行、最近结果 | +| 快速动作 | 启用/停用、编辑、手动运行、删除 | +| 空状态 | 引导用户创建一个计划任务 | + +定时任务管理保留现在 `/cron` 页的主要能力,但归入 Task 管理页,不再作为顶层独立概念。 + +已确认: + +1. 定时任务可以暂时关闭,也可以删除。 +2. 定时任务没有归档概念。 +3. 每次定时触发都创建普通 Task。 +4. 定时任务运行历史必须链接到对应普通 Task。 + +### 3.4 普通任务详情页 + +路径建议:`/tasks/{task_id}` + +建议分区: + +| 区域 | 必须展示 | 说明 | +|---|---|---| +| Task header | 标题、description、状态、来源会话、创建时间、更新时间 | 用户先知道自己看的是哪个任务 | +| 当前状态区 | 大字状态 + 当前阶段 + 下一步动作 | 例如“验证失败,等待修订” | +| 执行链条 | plan、nodes、dependency、main synthesis、validation | 这是本页核心 | +| 节点详情 | 每个节点的任务、状态、输入、输出、skills、错误 | 点击链条节点后在详情区展示 | +| 最终结果 | 主 Agent synthesis 的用户可见结果 | 不把 sub-agent summary 当最终结果 | +| 验证区 | 验证状态、分数、失败原因、重试记录 | 大状态明显,详情折叠 | +| 反馈区 | 满意、需要修改、放弃;评论框 | 和对话页保持一致 | +| 产物区 | 文件、链接、JSON、图片、报告 | 需要先定义产物来源 | +| 事件时间线 | 重要事件,不展示原始隐藏 JSON | 用产品化语言展示 | + +### 3.5 定时任务详情/编辑页 + +路径建议:`/tasks/scheduled/{job_id}` + +| 区域 | 必须展示 | 说明 | +|---|---|---| +| Job header | 名称、启用状态、创建时间、更新时间 | 用户先知道这是哪个定时任务 | +| 计划规则 | at / every / cron,下一次运行时间 | 核心配置 | +| 消息内容 | 触发时发送给 Agent 的 message | 可编辑 | +| 目标会话 | 触发运行归属哪个 session | 可选择或固定 | +| 运行历史 | 每次触发的时间、状态、run/task 链接 | 和普通任务结果打通 | +| 错误区 | 最近错误、失败原因 | 方便排查 | +| 操作区 | 保存、启用/停用、手动运行、删除 | 管理动作 | + +--- + +## 4. 普通任务详情:执行链条怎么展示 + +这是普通任务详情页最核心的问题。删除图形化 Office 后,需要一个清晰的链条视图。 + +### 方案 A:纵向阶段链 + +```text +Plan + ↓ +Team execution + ├─ Node A + ├─ Node B + └─ Node C + ↓ +Main synthesis + ↓ +Validation + ↓ +Feedback +``` + +优点: + +1. 最容易读。 +2. 移动端友好。 +3. 和事件时间顺序一致。 + +缺点: + +1. 对 parallel / dag 的依赖关系表达较弱。 + +### 方案 B:DAG 链条图 + +```text +Planner + ├── Node A ──┐ + ├── Node B ──┼── Synthesis ── Validation + └── Node C ──┘ +``` + +优点: + +1. 能清楚表达依赖关系。 +2. 对 team/dag 任务更准确。 + +缺点: + +1. 实现和布局复杂。 +2. 小屏幕容易难读。 + +### 方案 C:阶段链 + 节点分组 + +纵向展示阶段,在 team execution 阶段内部用节点分组表达 sequence / parallel / dag: + +```text +Plan + ↓ +Team execution + Group 1: Node A + Node B 并行 + Group 2: Node C 依赖 A/B + ↓ +Main synthesis + ↓ +Validation +``` + +优点: + +1. 兼顾可读性和结构。 +2. 比纯 DAG 更适合产品界面。 +3. 能覆盖 sequence / parallel / dag。 + +缺点: + +1. 需要后端或前端把 DAG 分层。 + +已确认: + +> 普通任务详情执行链条采用方案 C:阶段链 + 节点分组。 + +--- + +## 5. 普通任务详情:节点卡片需要展示什么 + +| 信息 | 是否必须 | 说明 | +|---|---|---| +| 节点标题 / node_id | 必须 | 让用户知道哪个子任务 | +| 状态 | 必须 | queued / running / done / error / blocked | +| 分配目的 | 必须 | 这个节点被安排做什么 | +| selected skills | 必须 | 体现为什么它按某种方式做 | +| generated ephemeral skill | 必须 | 如果系统临时生成了 draft-only guidance,需要可见 | +| 输出摘要 | 必须 | 用户不展开也能知道节点结果 | +| 错误/阻断原因 | 必须 | 失败时必须清楚 | +| 工具调用 | 待讨论 | 是否展示全部,还是只展示关键工具活动 | +| token/model/provider | 可选 | 可能只放调试展开区 | +| run_id/session_id | 可选 | 开发者调试用,不默认展示 | + +已确认: + +1. sub-agent 输出属于节点结果。 +2. sub-agent 输出不算产物。 + +--- + +## 6. 普通任务状态设计 + +用户可见状态建议不要完全照搬内部状态,而是做产品化映射。 + +| 用户可见状态 | 内部来源 | 说明 | +|---|---|---| +| 已创建 | task created / open | 已创建但还未开始执行 | +| 规划中 | task_execution_planned 前后 | 正在决定 single/team 和执行结构 | +| 执行中 | team run / main run running | 正在执行子任务或主综合 | +| 验证中 | validating | 正在自动验证最终结果 | +| 验证失败,正在重试 | validation failed + retry_scheduled | 第一次失败,系统自动重试 | +| 等待反馈 | awaiting_feedback | 最终结果已给出,等用户满意/修改/放弃 | +| 需要修改 | needs_revision | 用户要求修订,下一条消息复用该 Task | +| 已完成 | closed | 用户满意并关闭 | +| 已放弃 | abandoned | 用户放弃 | +| 任务失败 | 第二次验证失败 / unrecoverable failure | 执行失败且没有恢复,或第二次验证仍失败 | + +已确认: + +1. 第二次验证失败后,用户可见状态叫“任务失败”。 +2. 用户可以暂停、取消、重试普通 Task 或节点。 + +待讨论: + +1. 用户继续聊天默认满意后,状态是否直接变“已完成”。 +2. 暂停/取消/重试分别作用于整个 Task 还是当前节点时,状态如何显示。 + +--- + +## 7. 普通任务反馈与修订 + +普通任务详情页和对话页要保持一致。 + +必须支持: + +1. 满意。 +2. 需要修改。 +3. 放弃。 +4. 满意/需要修改的评论框。 +5. 用户在对话页继续聊天时,默认上一轮 Task 结果满意。 +6. 普通任务详情页允许直接输入修订意见。 + +待讨论: + +1. 详情页内输入修订意见时,是否同步写回来源会话消息流。 +2. 放弃是否需要原因。 + +已确认: + +1. 已放弃普通任务不允许恢复。 + +--- + +## 8. 普通任务详情:产物区 + +产物需要先定义清楚,否则会变成空面板。 + +可能的产物类型: + +| 类型 | 例子 | 来源 | +|---|---|---| +| 文件 | 生成的报告、代码、表格 | filesystem tool / workspace | +| 链接 | 搜索结果、外部引用 | web tool / MCP | +| JSON | 结构化计划、评估报告 | hidden events / validation | +| 图片 | 生成图、上传图分析结果 | attachment / tool | +| 文本报告 | 节点输出、最终总结 | main synthesis / sub-agent | + +待讨论: + +1. 无。 + +已确认: + +1. sub-agent 输出只算节点结果,不算产物。 +2. 产物区需要下载按钮。 +3. 下载可以支持单个产物下载或全部下载。 +4. validation report 属于验证区,不算产物。 +5. 用户上传附件不出现在 Task 产物区。 + +--- + +## 9. 任务管理页不应该做什么 + +| 不应该 | 原因 | +|---|---| +| 不再展示图形化办公室/地图/人物移动 | 用户要看执行链条,不是看装饰性现场 | +| 不允许手动选择 team strategy | 当前 strategy 是 Main Agent/Planner 内部决策 | +| 不允许手动选择 specialist agent | 当前 Task sub-agent 是 generic worker + skill guidance | +| 不直接暴露隐藏事件 JSON | 需要投影成产品可读事件 | +| 不混淆最终回答和中间节点输出 | 最终回答仍来自主 Agent synthesis | +| 不承载 Skills 审核发布 | 去 Skills 页 | +| 不承载 MCP/插件/Outlook 管理 | 去对应设置/管理页,是否保留后续讨论 | +| 不把定时任务和普通任务混成一张无区分列表 | 两者来源、动作和状态完全不同 | +| 不给任务设计归档概念 | session 有归档;任务没有归档 | + +--- + +## 10. 与对话页的关系 + +| 对话页 | 任务管理页 | +|---|---| +| 提交复杂任务 | 普通任务列表出现该任务 | +| 显示 Task 创建消息 | 打开普通任务详情 | +| 展示最终回答 | 展示最终回答如何生成 | +| 提供轻量反馈 | 提供完整反馈和修订上下文 | +| 用户继续聊天默认满意 | Task 状态同步关闭 | +| 不展示完整链条 | 展示链条、节点、验证、产物 | +| 不创建定时任务 | 定时任务 tab 创建和管理计划任务 | + +--- + +## 11. 已确认问题 + +| 问题 | 结论 | +|---|---| +| 路由是否改为 `/tasks`,并让旧 `/office` 跳转到 `/tasks`? | 是 | +| 任务管理入口是否分为“普通任务 / 定时任务”两个 tab? | 是 | +| 当前 `/cron` 能力是否并入“定时任务”tab,不再作为顶层导航? | 是 | +| 普通任务列表是否需要存在,还是主要从对话跳普通任务详情? | 需要存在,可以从列表进入详情 | +| 普通任务详情执行链条是否采用“阶段链 + 节点分组”方案? | 是 | +| 普通任务详情是否允许直接输入修订意见,还是必须回到对话页? | 允许直接输入 | +| 普通任务详情内输入修订意见时,是否同步写回来源会话消息流? | 无需同步 | +| 用户是否可以暂停、取消、重试普通 Task 或节点? | 前端只能取消或重试整个任务;没有暂停;不操作单个节点 | +| 第二次验证失败后,普通任务用户可见状态叫什么? | 任务失败 | +| sub-agent 输出算产物,还是只算节点结果? | 节点结果 | +| 产物是否需要下载/导出全部? | 具备下载按钮,支持单个下载或全部下载 | +| validation report 是否算产物,还是只属于验证区? | 属于验证区 | +| 用户上传附件是否出现在 Task 产物区? | 无需 | +| 已归档 session 里的普通任务是否还在普通任务列表显示? | 需要显示 | +| 已放弃普通任务是否允许恢复? | 不允许 | +| 定时任务删除语义是删除、禁用,还是归档? | 可以暂时关闭,也可以删除;所有任务没有归档概念 | +| 定时任务每次触发是否创建普通 Task,并在运行历史里链接过去? | 是 | +| tabs 名称最终用“普通任务 / 定时任务”,还是“任务 / 计划任务”? | 普通任务 / 定时任务 | + +## 12. 本页剩余待讨论问题 + +1. 是否需要任务搜索。 + +说明:“全局搜索任务”指任务管理页里是否需要一个搜索框,可以跨普通任务和定时任务搜索任务标题、description、来源会话、状态、定时任务消息内容等。不是全站搜索,也不是搜索聊天内容。 + +--- + +## 第 3 页:技能页 + +建议路径:`/skills` + +一句话定位: + +> 技能页是 Agent 能力生命周期管理台。它负责查看已发布技能、处理学习候选、生成和审核草稿、查看安全/评估报告,并把通过审核的技能发布到 runtime catalog。 + +它不是聊天页,也不是直接在线编辑 published skill 的地方。 + +已确认的方向: + +1. 主 tab 确定为“已发布 / 候选 / 草稿评审”。 +2. “运行学习”按钮不放在技能页。 +3. 保留技能上传;上传后必须进入 draft/review 流程。 +4. 保留技能下载。 +5. 允许删除技能。 +6. 已发布技能暂时不需要版本历史和 diff。 +7. 草稿评审需要 diff 视图。 +8. approve/reject 不强制填写 notes。 +9. publish 不需要二次确认。 +10. high risk draft 允许发布,但必须展示理由。 +11. candidate 允许忽略/关闭。 +12. candidate 和 draft 不需要链接回 source task/run。 + +--- + +## 1. 用户打开技能页是为了什么 + +| 用户目标 | 页面需要支持 | +|---|---| +| 看当前有哪些技能 | 展示已发布技能、状态、来源、描述、版本 | +| 看系统从任务中学到了什么 | 展示 learning candidates、来源 run/task、原因和风险 | +| 生成技能草稿 | 从 candidate 生成 draft,或重新生成 draft | +| 审核技能草稿 | 查看 proposed content、frontmatter、review 状态 | +| 判断草稿是否安全 | 查看 safety report、risk level、阻断原因 | +| 判断草稿是否有效 | 查看 eval report、是否通过、provider unavailable 等状态 | +| 发布技能 | approved + safety passed + eval not failed 后发布 | +| 管理已发布技能 | 禁用、回滚、删除、下载 | +| 上传技能 | 上传后进入 draft/review 流程 | +| 处理无价值候选 | 忽略/关闭 candidate | + +--- + +## 2. 技能页应该覆盖的生命周期 + +```text +Task 成功 + 用户满意 +│ +└─ learning candidate + ├─ open + ├─ queued + ├─ synthesizing + ├─ draft_ready + ├─ safety_failed + ├─ eval_failed + ├─ review_pending + ├─ approved + ├─ rejected + ├─ published + ├─ failed + └─ superseded + │ + └─ draft + ├─ safety report + ├─ eval report + ├─ submit review + ├─ approve / reject + └─ publish / disable / rollback +``` + +核心约束: + +1. draft 不进入 runtime catalog。 +2. rejected draft 不可 publish。 +3. publish 必须要求 approved review + safety passed + eval not failed。 +4. high risk publish 需要显式确认。 +5. worker 可以自动到 draft/safety/eval,但永不自动 approve/publish。 +6. 不允许绕过 lifecycle 直接在线改 published skill。 + +--- + +## 3. 页面结构建议 + +技能页建议保留三个主 tab: + +| Tab | 目的 | +|---|---| +| 已发布 | 查看 runtime catalog 中当前可用/不可用的技能 | +| 候选 | 查看 learning candidates,决定是否生成/重生成草稿 | +| 草稿/评审 | 审核 draft,查看 safety/eval,批准/拒绝/发布 | + +### 3.1 已发布 tab + +| 区域 | 内容 | +|---|---| +| 技能列表 | 名称、描述、来源、状态、当前版本、更新时间 | +| 状态标识 | available / unavailable / disabled / retired | +| 操作 | 查看详情、禁用、回滚、删除、下载、上传 | +| 可选操作 | 复制路径、查看支持文件 | + +已确认: + +1. 保留上传技能。 +2. 上传后必须进入 draft/review 流程。 +3. 保留下载技能。 +4. 允许删除技能。 +5. 已发布技能暂时不需要版本历史和 diff。 + +### 3.2 候选 tab + +| 区域 | 内容 | +|---|---| +| 候选列表 | candidate id、kind、status、risk、reason、evidence summary | +| 来源信息 | source run ids、task id、相关技能 | +| 操作 | 生成草稿、重新生成、查看详情、忽略/关闭 | + +候选类型: + +| 类型 | 说明 | +|---|---| +| new_skill | 新建技能建议 | +| revise_skill | 修订已有技能建议 | +| merge_skills | 合并技能建议 | +| retire_skill | 退役技能建议 | + +待讨论: + +1. 是否允许用户手动创建 candidate。 + +已确认: + +1. 技能页不放“运行学习”按钮。 +2. candidate 允许忽略/关闭。 +3. candidate 不需要链接回 source task/run。 + +### 3.3 草稿/评审 tab + +| 区域 | 内容 | +|---|---| +| 草稿列表 | skill name、draft id、proposal kind、status、base version | +| 内容区 | proposed frontmatter、proposed content | +| Safety report | passed、risk level、findings | +| Eval report | passed、status、notes | +| Review | submit、approve、reject、review notes | +| Diff | base version vs proposed draft | +| Publish | publish、high risk reason | + +已确认: + +1. 草稿评审需要 diff 视图。 +2. approve/reject 不强制填写 notes。 +3. publish 不需要二次确认。 +4. high risk draft 允许发布,但必须展示理由。 +5. draft 不需要链接回 source task/run。 + +--- + +## 4. 已发布技能管理边界 + +| 动作 | 当前建议 | 说明 | +|---|---|---| +| 查看 | 必须 | 看名称、描述、版本、状态 | +| 禁用 | 必须 | 保留 skill spec,不进入 runtime selection | +| 回滚 | 必须 | 通过 publisher 回滚到旧版本 | +| 删除 | 必须 | 允许删除技能,暂时不需要二次确认 | +| 上传 | 必须 | 上传后进入 draft/review 流程,并自动跑 safety 和 eval | +| 下载 | 必须 | 作为备份/迁移能力保留 | +| 在线编辑 published | 不允许 | 必须通过 draft -> review -> publish | + +--- + +## 5. 技能页和其它页面的关系 + +| 页面 | 关系 | +|---|---| +| 对话页 | 用户满意反馈触发学习候选;对话页不审核技能 | +| 任务管理页 | 技能页不需要链接回 source task/run | +| 状态页 | provider 不可用会影响 draft synthesis/eval,技能页只显示结果,不管理 provider | +| MCP/工具页 | skill 可有 tool hints,但技能页不管理 MCP server | + +--- + +## 6. 技能页不应该做什么 + +| 不应该 | 原因 | +|---|---| +| 不直接编辑 published skill | 破坏 review/publish lifecycle | +| 不自动 approve/publish | 当前设计是 assisted learning | +| 不让 rejected draft 发布 | 审核状态必须生效 | +| 不让 safety_failed / eval_failed 绕过发布 | 安全和评估是发布门 | +| 不把 draft 当 runtime skill | draft 不进入 runtime catalog | +| 不把技能页做成普通文件管理器 | 技能是生命周期对象,不只是 Markdown 文件 | +| 不在技能页放 run learning | 学习 worker 手动触发不属于这个页面 | +| 不要求 candidate/draft 跳回 source task/run | 技能页只展示必要 evidence 摘要 | + +--- + +## 7. 已确认问题 + +| 问题 | 结论 | +|---|---| +| 技能页主 tab 是否确定为“已发布 / 候选 / 草稿评审”? | 是 | +| 是否保留“运行学习”按钮,允许用户手动触发 worker run-once? | 不在技能页做 | +| 是否保留技能上传?如果保留,上传后是否必须进入 draft/review 流程? | 保留,且必须进入 draft/review 流程 | +| 是否保留技能下载? | 是 | +| 是否允许删除技能,还是只允许禁用/回滚/退役? | 允许删除 | +| 技能删除是否需要二次确认? | 暂时不需要 | +| 技能上传后是否自动跑 safety/eval? | 是,上传后自动跑 safety 和 eval | +| 已发布技能是否需要版本历史和 diff? | 暂时无需 | +| 草稿评审是否需要 diff 视图? | 需要 | +| approve/reject 是否必须填写 notes? | 无需 | +| publish 是否必须二次确认? | 无需 | +| high risk draft 是否允许发布,还是只能重新生成/拒绝? | 允许,但要展示来自 safety report 的理由 | +| high risk draft 的“理由”来自 safety report,还是需要发布者手动填写? | 来自 safety report | +| candidate 是否允许忽略/关闭? | 是 | +| candidate 和 draft 是否需要链接回 source task/run? | 无需 | + +## 8. 本页剩余待讨论问题 + +暂无。 diff --git a/app-instance/sessions/state.db b/app-instance/sessions/state.db new file mode 100644 index 0000000..c832ad3 Binary files /dev/null and b/app-instance/sessions/state.db differ diff --git a/sessions/state.db b/sessions/state.db index 06b1fb1..6ecf7d1 100644 Binary files a/sessions/state.db and b/sessions/state.db differ diff --git a/前端改造风格/0771910121417d983ca9fc61a48e13f7.png b/前端改造风格/0771910121417d983ca9fc61a48e13f7.png new file mode 100644 index 0000000..09b6c2e Binary files /dev/null and b/前端改造风格/0771910121417d983ca9fc61a48e13f7.png differ diff --git a/前端改造风格/145e4ac39c635a3520edc41a5977db05.png b/前端改造风格/145e4ac39c635a3520edc41a5977db05.png new file mode 100644 index 0000000..05a1d30 Binary files /dev/null and b/前端改造风格/145e4ac39c635a3520edc41a5977db05.png differ diff --git a/前端改造风格/308a0bb342bf60287d0ff250eb7899d5.png b/前端改造风格/308a0bb342bf60287d0ff250eb7899d5.png new file mode 100644 index 0000000..6017b8e Binary files /dev/null and b/前端改造风格/308a0bb342bf60287d0ff250eb7899d5.png differ diff --git a/前端改造风格/30e8036792577193657519d3f671e7d9.png b/前端改造风格/30e8036792577193657519d3f671e7d9.png new file mode 100644 index 0000000..d5c09ac Binary files /dev/null and b/前端改造风格/30e8036792577193657519d3f671e7d9.png differ diff --git a/前端改造风格/3503b561cf9e1874915178dc24f2572b.png b/前端改造风格/3503b561cf9e1874915178dc24f2572b.png new file mode 100644 index 0000000..a131c3a Binary files /dev/null and b/前端改造风格/3503b561cf9e1874915178dc24f2572b.png differ diff --git a/前端改造风格/35e7c6e818c2ce11eac2c296e0bcbf9d.png b/前端改造风格/35e7c6e818c2ce11eac2c296e0bcbf9d.png new file mode 100644 index 0000000..0b2e3e6 Binary files /dev/null and b/前端改造风格/35e7c6e818c2ce11eac2c296e0bcbf9d.png differ diff --git a/前端改造风格/4193508999431017053ab885384d3754.png b/前端改造风格/4193508999431017053ab885384d3754.png new file mode 100644 index 0000000..960145c Binary files /dev/null and b/前端改造风格/4193508999431017053ab885384d3754.png differ diff --git a/前端改造风格/5501cfdc70081b1c9a8723347bdf6122.png b/前端改造风格/5501cfdc70081b1c9a8723347bdf6122.png new file mode 100644 index 0000000..da05c99 Binary files /dev/null and b/前端改造风格/5501cfdc70081b1c9a8723347bdf6122.png differ diff --git a/前端改造风格/64a29bddd827dce7b196104f92ea5621.png b/前端改造风格/64a29bddd827dce7b196104f92ea5621.png new file mode 100644 index 0000000..e77dd22 Binary files /dev/null and b/前端改造风格/64a29bddd827dce7b196104f92ea5621.png differ diff --git a/前端改造风格/8d98af7d0c96df7c7b4cded57efbba78.png b/前端改造风格/8d98af7d0c96df7c7b4cded57efbba78.png new file mode 100644 index 0000000..ef42601 Binary files /dev/null and b/前端改造风格/8d98af7d0c96df7c7b4cded57efbba78.png differ diff --git a/前端改造风格/DESIGN.md b/前端改造风格/DESIGN.md new file mode 100644 index 0000000..c6bd5a2 --- /dev/null +++ b/前端改造风格/DESIGN.md @@ -0,0 +1,411 @@ +# DESIGN.md + +## Brand + +**Beaver — Taupe** + +A calm editorial UI system focused on rhythm, hierarchy, and soft neutral contrast. +Designed for AI-native tools, dashboards, and minimalist productivity software. + +The interface emphasizes: + +- Spacious layouts +- Soft grayscale surfaces +- Typography-first hierarchy +- Rounded geometry +- Quiet shadows +- Dense information with low visual noise + +The visual tone should feel: + +- thoughtful +- mature +- calm +- premium +- architectural +- editorial + +Avoid: + +- saturated colors +- hard borders +- sharp corners +- excessive gradients +- loud shadows +- playful illustration-heavy UI + +--- + +# Colors + +## Core Palette + +| Token | Hex | Usage | +|---|---|---| +| background | `#F5F3F1` | Main app background | +| foreground | `#0B0B0B` | Primary text | +| primary | `#1D1715` | Primary actions | +| secondary | `#E5E2DF` | Secondary surfaces | +| muted | `#DDD9D6` | Muted backgrounds | +| accent | `#CAC5C0` | Borders / subtle emphasis | + +--- + +## Neutral Scale + +| Token | Hex | +|---|---| +| zinc-50 | `#F7F5F4` | +| zinc-100 | `#ECE8E5` | +| zinc-200 | `#D8D2CE` | +| zinc-300 | `#B8AEA8` | +| zinc-400 | `#8B7E77` | +| zinc-500 | `#6A5E58` | +| zinc-600 | `#4F4642` | +| zinc-700 | `#342E2B` | + +--- + +## Semantic Colors + +### Taupe + +| Step | Hex | +|---|---| +| taupe-100 | `#E7E2DE` | +| taupe-300 | `#B8AEA8` | +| taupe-500 | `#8B7E77` | +| taupe-700 | `#5F5550` | + +### Sage + +| Step | Hex | +|---|---| +| sage-100 | `#E3E8E2` | +| sage-300 | `#B7C2B5` | +| sage-500 | `#869683` | +| sage-700 | `#657162` | + +### Slate + +| Step | Hex | +|---|---| +| slate-100 | `#E4E7EB` | +| slate-300 | `#BCC4CE` | +| slate-500 | `#8C96A3` | +| slate-700 | `#697281` | + +--- + +# Typography + +## Philosophy + +Typography drives hierarchy. + +The system should feel like a modern editorial publication mixed with a productivity dashboard. + +Large headings use elegant serif typography. +UI and body copy use neutral grotesk sans-serif typography. + +--- + +## Font Stack + +### Serif + +```css +font-family: "Lora", Georgia, serif; +``` + +Used for: + +- hero titles +- article headings +- marketing emphasis +- editorial sections + +--- + +### Sans + +```css +font-family: "Public Sans", Inter, sans-serif; +``` + +Used for: + +- UI +- labels +- forms +- dashboards +- buttons +- navigation + +--- + +## Type Scale + +| Style | Size | Weight | Line Height | +|---|---|---|---| +| h1 | 48px | 600 | 1.1 | +| h2 | 36px | 600 | 1.15 | +| h3 | 28px | 500 | 1.2 | +| body-lg | 18px | 400 | 1.7 | +| body | 16px | 400 | 1.6 | +| small | 14px | 400 | 1.5 | +| mono | 13px | 500 | 1.4 | + +--- + +# Radius + +Rounded geometry should feel soft but architectural. + +| Token | Radius | +|---|---| +| xs | 4px | +| sm | 8px | +| md | 12px | +| lg | 16px | +| xl | 24px | +| full | 999px | + +Cards should primarily use: + +```css +border-radius: 16px; +``` + +--- + +# Shadows + +Shadows should be subtle and diffused. + +Avoid strong elevation. + +## Soft + +```css +box-shadow: +0 1px 2px rgba(0,0,0,0.04), +0 6px 24px rgba(0,0,0,0.03); +``` + +## Floating + +```css +box-shadow: +0 12px 40px rgba(0,0,0,0.06); +``` + +--- + +# Grid + +## Layout + +- 12-column grid +- Max width: `1280px` +- Horizontal padding: `32px` +- Large whitespace between sections + +--- + +## Content Widths + +| Type | Width | +|---|---| +| reading | 720px | +| dashboard | 1280px | +| modal | 480px | +| form | 560px | + +--- + +# Spacing + +Base unit: + +```txt +4px +``` + +Spacing scale: + +| Token | Value | +|---|---| +| 1 | 4px | +| 2 | 8px | +| 3 | 12px | +| 4 | 16px | +| 5 | 20px | +| 6 | 24px | +| 8 | 32px | +| 10 | 40px | +| 12 | 48px | +| 16 | 64px | + +Use generous vertical rhythm. + +Sections should breathe. + +--- + +# Components + +## Buttons + +### Primary + +- Dark background +- White text +- Pill radius +- Minimal shadow + +```css +background: #1D1715; +color: white; +border-radius: 999px; +height: 40px; +padding: 0 16px; +``` + +### Secondary + +```css +background: #ECE8E5; +color: #1D1715; +``` + +### Ghost + +Transparent background with subtle hover fill. + +--- + +## Cards + +Cards are soft containers with quiet separation. + +```css +background: rgba(255,255,255,0.7); +border: 1px solid rgba(0,0,0,0.04); +border-radius: 16px; +``` + +Avoid heavy borders. + +--- + +## Inputs + +Inputs should feel invisible until focused. + +```css +background: #F7F5F4; +border: 1px solid transparent; +``` + +Focus: + +```css +border-color: #8B7E77; +box-shadow: 0 0 0 3px rgba(139,126,119,0.12); +``` + +--- + +## Charts + +Charts should use muted earthy tones. + +Preferred palette: + +- taupe +- sage +- slate + +Avoid: + +- neon colors +- bright blue dashboards +- rainbow charts + +--- + +# Motion + +Motion should be restrained and smooth. + +Preferred easing: + +```css +cubic-bezier(0.22, 1, 0.36, 1) +``` + +Preferred duration: + +| Type | Duration | +|---|---| +| hover | 150ms | +| panel | 250ms | +| modal | 350ms | + +--- + +# Layout Skeleton + +Application layout: + +- Left sidebar +- Large content canvas +- Floating top toolbar +- Soft dashboard cards +- Spacious internal padding + +The UI should always feel: + +- breathable +- editorial +- premium +- calm + +Never dense or overly enterprise-looking. + +--- + +# Design Keywords + +Use these words when generating UI: + +- editorial +- taupe +- soft neutral +- premium minimal +- typography-first +- architectural spacing +- calm dashboard +- quiet luxury +- modern serif +- subtle shadows +- muted grayscale +- sophisticated SaaS + +--- + +# AI Agent Instructions + +When generating UI: + +1. Prioritize whitespace over density +2. Typography should create hierarchy +3. Use muted neutral palettes +4. Prefer soft cards over hard sections +5. Avoid excessive color usage +6. Keep interactions subtle +7. Use serif fonts sparingly for emphasis +8. Maintain premium visual restraint +9. Design should feel timeless rather than trendy +10. Every screen should feel breathable diff --git a/前端改造风格/a900c98901245c5796e01f0c150e5ec3.png b/前端改造风格/a900c98901245c5796e01f0c150e5ec3.png new file mode 100644 index 0000000..74d5584 Binary files /dev/null and b/前端改造风格/a900c98901245c5796e01f0c150e5ec3.png differ diff --git a/前端改造风格/abe6200a6ee2459da3e3d0dc69c19bfa.png b/前端改造风格/abe6200a6ee2459da3e3d0dc69c19bfa.png new file mode 100644 index 0000000..c1cc877 Binary files /dev/null and b/前端改造风格/abe6200a6ee2459da3e3d0dc69c19bfa.png differ diff --git a/前端改造风格/c32570e98dbb7e8a6f19f951811cd129.png b/前端改造风格/c32570e98dbb7e8a6f19f951811cd129.png new file mode 100644 index 0000000..51d25c7 Binary files /dev/null and b/前端改造风格/c32570e98dbb7e8a6f19f951811cd129.png differ diff --git a/前端改造风格/c5062c18c83c338aa7af50c96f9a3b59.png b/前端改造风格/c5062c18c83c338aa7af50c96f9a3b59.png new file mode 100644 index 0000000..04951b1 Binary files /dev/null and b/前端改造风格/c5062c18c83c338aa7af50c96f9a3b59.png differ diff --git a/前端改造风格/cf74720c590e7747562eaf8f79c73b21.png b/前端改造风格/cf74720c590e7747562eaf8f79c73b21.png new file mode 100644 index 0000000..15eb2c6 Binary files /dev/null and b/前端改造风格/cf74720c590e7747562eaf8f79c73b21.png differ