diff --git a/README-zh.md b/README-zh.md index 0c463869..3bf62066 100755 --- a/README-zh.md +++ b/README-zh.md @@ -170,6 +170,33 @@ make dev ``` 检查所有 YAML 文件的语法与 schema 错误。 +### 🦞 使用 OpenClaw 运行 + +OpenClaw 可以与 ChatDev 集成,通过 **调用已有的 agent 团队**,或在 ChatDev 中 **动态创建新的 agent 团队** 来完成任务。 + +开始使用: + +1. 启动 ChatDev 2.0 后端。 +2. 为你的 OpenClaw 实例安装所需的技能: + + ```bash + clawdbot install chatdev + ``` + +3. 让 OpenClaw 创建一个 ChatDev 工作流。例如: + + * **自动化信息收集与内容发布** + + ``` + 创建一个 ChatDev 工作流,用于自动收集热点信息,生成一篇小红书文案,并发布该内容 + ``` + + * **多智能体地缘政治模拟** + + ``` + 创建一个 ChatDev 工作流,构建多个 agent,用于模拟中东局势未来可能的发展 + ``` + ### 🐳 使用 Docker 运行 你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。 diff --git a/frontend/src/pages/LaunchView.vue b/frontend/src/pages/LaunchView.vue index 748c7a41..8dbf800a 100755 --- a/frontend/src/pages/LaunchView.vue +++ b/frontend/src/pages/LaunchView.vue @@ -582,6 +582,10 @@ const addLoadingEntry = (nodeId, baseKey, label) => { nodeState.entryMap.set(key, entry) nodeState.baseKeyToKey.set(baseKey, key) nodeState.message.loadingEntries.push(entry) + runningLoadingEntries.value += 1 + if (runningLoadingEntries.value === 1) { + startLoadingTimer() + } return entry } @@ -594,22 +598,37 @@ const finishLoadingEntry = (nodeId, baseKey) => { const entry = key ? nodeState.entryMap.get(key) : null if (!entry) return null + const wasRunning = entry.status === 'running' entry.status = 'done' entry.endedAt = Date.now() nodeState.baseKeyToKey.delete(baseKey) + if (wasRunning) { + runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - 1) + if (runningLoadingEntries.value === 0) { + stopLoadingTimer() + } + } return entry } // Finish all running entries when a node ends or cancels const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => { if (!nodeState) return + let finishedCount = 0 for (const entry of nodeState.entryMap.values()) { if (entry.status === 'running') { entry.status = 'done' entry.endedAt = endedAt + finishedCount += 1 } } nodeState.baseKeyToKey.clear() + if (finishedCount) { + runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - finishedCount) + if (runningLoadingEntries.value === 0) { + stopLoadingTimer() + } + } } // Global timer for updating loading bubble durations @@ -876,10 +895,12 @@ const addDialogue = (name, message) => { const isRight = name === "User" + const htmlContent = renderMarkdown(text) chatMessages.value.push({ type: 'dialogue', name: name, text: text, + htmlContent, avatar: avatar, isRight: isRight, timestamp: Date.now() @@ -1497,13 +1518,6 @@ onMounted(() => { document.addEventListener('click', handleClickOutside) document.addEventListener('keydown', handleKeydown) loadWorkflows() - - // Start the global timer - if (!loadingTimerInterval) { - loadingTimerInterval = setInterval(() => { - now.value = Date.now() - }, 1000) - } }) onUnmounted(() => { @@ -1513,10 +1527,8 @@ onUnmounted(() => { resetConnectionState() cleanupRecording() - if (loadingTimerInterval) { - clearInterval(loadingTimerInterval) - loadingTimerInterval = null - } + stopLoadingTimer() + runningLoadingEntries.value = 0 }) const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow() diff --git a/runtime/node/agent/tool/tool_manager.py b/runtime/node/agent/tool/tool_manager.py index 864d6c6e..bf7276a8 100755 --- a/runtime/node/agent/tool/tool_manager.py +++ b/runtime/node/agent/tool/tool_manager.py @@ -9,7 +9,6 @@ import logging import mimetypes import os import threading -import time from pathlib import Path from typing import Any, Dict, List, Mapping, Sequence @@ -36,19 +35,13 @@ class _FunctionManagerCacheEntry: auto_loaded: bool = False -@dataclass -class _McpToolCacheEntry: - tools: List[Any] - fetched_at: float - - class ToolManager: """Manage function tools for agent nodes.""" def __init__(self) -> None: self._functions_dir: Path = FUNCTION_CALLING_DIR self._function_managers: Dict[Path, _FunctionManagerCacheEntry] = {} - self._mcp_tool_cache: Dict[str, _McpToolCacheEntry] = {} + self._mcp_tool_cache: Dict[str, List[Any]] = {} self._mcp_stdio_clients: Dict[str, "_StdioClientWrapper"] = {} def _get_function_manager(self) -> FunctionManager: @@ -199,25 +192,19 @@ class ToolManager: def _build_mcp_remote_specs(self, config: McpRemoteConfig) -> List[ToolSpec]: cache_key = f"remote:{config.cache_key()}" - tools = self._get_mcp_tools( - cache_key, - config.cache_ttl, - lambda: asyncio.run( + tools = self._mcp_tool_cache.get(cache_key) + if tools is None: + tools = asyncio.run( self._fetch_mcp_tools_http( config.server, headers=config.headers, timeout=config.timeout, ) - ), - ) + ) + self._mcp_tool_cache[cache_key] = tools specs: List[ToolSpec] = [] - allowed_sources = {source for source in (config.tool_sources or []) if source} for tool in tools: - meta = getattr(tool, "meta", None) - source = meta.get("source") if isinstance(meta, Mapping) else None - if allowed_sources and source not in allowed_sources: - continue specs.append( ToolSpec( name=tool.name, @@ -234,11 +221,10 @@ class ToolManager: raise ValueError("MCP local configuration missing launch key") cache_key = f"stdio:{launch_key}" - tools = self._get_mcp_tools( - cache_key, - config.cache_ttl, - lambda: asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key)), - ) + tools = self._mcp_tool_cache.get(cache_key) + if tools is None: + tools = asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key)) + self._mcp_tool_cache[cache_key] = tools specs: List[ToolSpec] = [] for tool in tools: @@ -252,25 +238,28 @@ class ToolManager: ) return specs - def _get_mcp_tools( + def _execute_function_tool( self, - cache_key: str, - cache_ttl: float, - fetcher, - ) -> List[Any]: - entry = self._mcp_tool_cache.get(cache_key) - if entry and self._is_cache_fresh(entry.fetched_at, cache_ttl): - return entry.tools - tools = fetcher() - self._mcp_tool_cache[cache_key] = _McpToolCacheEntry(tools=tools, fetched_at=time.time()) - return tools - - @staticmethod - def _is_cache_fresh(fetched_at: float, cache_ttl: float) -> bool: - if cache_ttl <= 0: - return False - return (time.time() - fetched_at) < cache_ttl + tool_name: str, + arguments: Dict[str, Any], + config: FunctionToolConfig, + tool_context: Dict[str, Any] | None = None, + ) -> Any: + mgr = self._get_function_manager() + if config.auto_load: + mgr.load_functions() + func = mgr.get_function(tool_name) + if func is None: + raise ValueError(f"Tool {tool_name} not found in {self._functions_dir}") + call_args = dict(arguments or {}) + if ( + tool_context is not None + # and "_context" not in call_args + and self._function_accepts_context(func) + ): + call_args["_context"] = tool_context + return func(**call_args) def _function_accepts_context(self, func: Any) -> bool: try: diff --git a/server/mcp_server.py b/server/mcp_server.py deleted file mode 100644 index 6ec2fe20..00000000 --- a/server/mcp_server.py +++ /dev/null @@ -1,187 +0,0 @@ -import importlib.util -import inspect -import random -import uuid -from pathlib import Path -from typing import Any, Iterable - -from fastmcp import FastMCP -from fastmcp.tools import FunctionTool -from starlette.requests import Request -from starlette.responses import JSONResponse - -# Repo root (for loading built-in function tools). -_REPO_ROOT = Path(__file__).resolve().parents[1] -_FUNCTION_CALLING_DIR = _REPO_ROOT / "functions" / "function_calling" - -# MCP server for production use (supports hot-updated tools). -mcp = FastMCP( - "DevAll MCP Server", - debug=True, -) - -_DYNAMIC_TOOLS_DIR = Path(__file__).parent / "dynamic_tools" -_DYNAMIC_TOOLS_DIR.mkdir(parents=True, exist_ok=True) - - -def _safe_tool_filename(filename: str) -> str: - name = Path(filename).name - if not name.endswith(".py"): - raise ValueError("filename must end with .py") - return name - - -def _load_module_from_path(path: Path) -> Any: - module_name = f"_dynamic_mcp_{path.stem}_{uuid.uuid4().hex}" - spec = importlib.util.spec_from_file_location(module_name, path) - if spec is None or spec.loader is None: - raise ValueError("failed to create module spec") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _select_functions(module: Any, allowlist: Iterable[str] | None) -> list[Any]: - allowset = {name.strip() for name in allowlist} if allowlist else None - selected = [] - for name, obj in inspect.getmembers(module, inspect.isfunction): - if name.startswith("_"): - continue - if getattr(obj, "__module__", None) != module.__name__: - continue - if allowset is not None and name not in allowset: - continue - selected.append(obj) - if allowset: - missing = sorted(allowset - {fn.__name__ for fn in selected}) - if missing: - raise ValueError(f"functions not found in module: {', '.join(missing)}") - return selected - - -def _register_functions(functions: Iterable[Any], *, replace: bool, source: str) -> list[str]: - added: list[str] = [] - for fn in functions: - tool = FunctionTool.from_function(fn, meta={"source": source}) - if replace: - try: - mcp.remove_tool(tool.name) - except Exception: - pass - mcp.add_tool(tool) - added.append(tool.name) - return added - - -def register_tools_from_payload(payload: dict) -> dict: - filename = payload.get("filename") - content = payload.get("content") - function_names = payload.get("functions") - replace = payload.get("replace", True) - - if not isinstance(filename, str) or not filename.strip(): - raise ValueError("filename is required") - if not isinstance(content, str) or not content.strip(): - raise ValueError("content is required") - if function_names is not None and not isinstance(function_names, list): - raise ValueError("functions must be a list of strings") - - safe_name = _safe_tool_filename(filename.strip()) - - file_path = _DYNAMIC_TOOLS_DIR / safe_name - file_path.write_text(content, encoding="utf-8") - - module = _load_module_from_path(file_path) - functions = _select_functions(module, function_names) - if not functions: - raise ValueError("no functions found to register") - - added = _register_functions(functions, replace=bool(replace), source="dynamic_tools") - - return {"status": "ok", "added": added, "file": str(file_path)} - - -@mcp.custom_route("/admin/tools/upload", methods=["POST"]) -async def upload_tool(request: Request) -> JSONResponse: - try: - payload = await request.json() - result = register_tools_from_payload(payload) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except Exception as exc: - return JSONResponse({"error": f"failed to load tools: {exc}"}, status_code=400) - - return JSONResponse(result) - - -@mcp.custom_route("/admin/tools/reload", methods=["POST"]) -async def reload_tools(request: Request) -> JSONResponse: - replace = True - try: - payload = await request.json() - if isinstance(payload, dict) and "replace" in payload: - replace = bool(payload["replace"]) - except Exception: - replace = True - result = load_tools_from_directory(replace=replace) - return JSONResponse(result) - - -@mcp.custom_route("/admin/tools/list", methods=["GET"]) -async def list_tools(request: Request) -> JSONResponse: - tools = await mcp.get_tools() - items = [] - for key in sorted(tools.keys()): - tool = tools[key] - meta = tool.meta or {} - source = meta.get("source", "unknown") - call_methods = ["mcp_remote"] - if source == "function_calling": - call_methods.append("function") - payload = { - "key": key, - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "enabled": tool.enabled, - "source": source, - "call_methods": call_methods, - } - if meta: - payload["meta"] = meta - if tool.output_schema is not None: - payload["output_schema"] = tool.output_schema - items.append(payload) - return JSONResponse({"tools": items}) - - -def load_tools_from_directory(*, replace: bool = True) -> dict: - added: list[str] = [] - errors: list[str] = [] - for directory in (_FUNCTION_CALLING_DIR, _DYNAMIC_TOOLS_DIR): - if not directory.exists(): - continue - source = "local_tools" if directory == _FUNCTION_CALLING_DIR else "mcp_tools" - for path in sorted(directory.glob("*.py")): - if path.name.startswith("_") or path.name == "__init__.py": - continue - try: - module = _load_module_from_path(path) - functions = _select_functions(module, None) - if not functions: - continue - added.extend(_register_functions(functions, replace=replace, source=source)) - except Exception as exc: - errors.append(f"{path.name}: {exc}") - return {"added": added, "errors": errors} - - -_bootstrap = load_tools_from_directory(replace=True) -if _bootstrap["errors"]: - print(f"Dynamic tool load errors: {_bootstrap['errors']}") - - -if __name__ == "__main__": - print("Starting MCP server...") - print("Run standalone with: fastmcp run server/mcp_server.py --transport streamable-http --port 8010") - mcp.run() \ No newline at end of file