职贝云数AI新零售门户

标题: 我也复刻了一个 Manus,带高仿 WebUI 和沙盒 [打印本页]

作者: gPEJ    时间: 昨天 18:01
标题: 我也复刻了一个 Manus,带高仿 WebUI 和沙盒
作者为摇一摇,出于学术/技术分享停止转载,如有侵权,联络删文。

原文链接:https://zhuanlan.zhihu.com/p/1903248937877477029

最近在学习 LLM Agent,但终觉“纸上学来终觉浅,绝知此事要躬行”,所以想写个小项目试试手。在这个人均写一个 Manus 的时代,在这个半开卷的状况下,况且 Manus 的提词曾经走漏的状况下,是不是我也可以写一个,我整理了一下本人的需求如下:

结合 Cursor 应该可以疾速地写出一个 Manus 的示例,听说 OpenManus 4 小时就写出来了。

项目地址:

https://github.com/Simpleyyt/ai-manus

github.com/Simpleyyt/ai-manus

01

Demo 演示

Code USE
Prompt:写一个复杂的 python 示例
Browser Use
Prompt: 义务:llm 最新论文效果可以说是相当的凑合,但是目前是学习目的,提示词与 Agent 流程能够都需求优化一下,懒得再调了,就交给广大网友了。
02

全体设计

全体系统由三个模块组成:Web、Server 与 Sandbox,用户运用流程如下:

当用户发起对话时:

当用户阅读工具时:


03

AI Agent 设计形式

AI Agent 是什么?置信大家都听烂了,我在这里说的一定不如别人说的好,简单来说就是:AI Agent = LLM + Planning + Memory + Tools。

(, 下载次数: 0)
FunctionCall or ReAct or LangChain?

先来说一下 Tool Use 部分,目前可以运用的方式是:1)高阶模型本身的 FunctionCall 才能;2)ReAct Prompt 框架;3)LangChain Agent 框架,其实它是前两者的高度封装。

最简单是运用 LangChain 这样高度封装的框架了,但是对于学习目的的项目来说,我不断激烈想知道它背后做了什么,所以这个先 Pass 掉了。

运用 FunctionCall 来运用工具要求比较高阶的模型,但 ReAct 的设计又太繁琐,思来想去,还是先运用 FunctionCall,先用好的模型开发,后面再来研讨 ReAct 框架。关于 ReAct ,感兴味可以看看:ReAct 框架 | Prompt Engineering Guide。

(, 下载次数: 0)

因此,该项目对 LLM 的要求如下:
Plan-and-Act Agent 设计形式

全体运用 Plan-and-Act 的 Agent 设计形式,相关论文:Plan-and-Act : Improving Planning of Agents for Long-Horizon Tasks,它的相关流程如下:

(, 下载次数: 0)

即将系统分成 Planner 和 Executor,Planner 将义务停止规划拆分,Executor 担任义务分步执行,将执行结果前往给 Planner 重新规划。

项目中的形态流转图如下:

系统支持被打断,一切打断的音讯都会流向 Planner Agent,Planner Agent 为根据用户的打断音讯重新规划。

04

Sandbox 设计

为了完成每个义务运用单独的 Docker 沙盒,我们 Server 经过/var/run/docker.sock停止机器上 Docker 沙盒的创建与销毁。
Sandbox 进程与生命周期管理

整个 Sandbox 经过 supervisord 进程管理,并且经过 supervisord 完成 Sandbox 的 TTL 管理。由于 Agent 目前没有自动销毁机制,所以需求 Sandbox 自动过期自销毁,并完成续时等接口。
File & Shell 工具

文件操作与 Shell 命令执行没有什么难度,Cursor 最擅长这些,我把 Manus 的工具描画丢给 Cursor 后,很快就用 FastAPI 帮我生成了一整套代码,不得不说很波动,基本没有怎样改过。
Browser 工具

目前遍地的 Manus 都在运用 browser-use(https://github.com/browser-use/browser-use)这个库,为了学习和研讨的目的,我还是决议运用 Playwright + Chrome 本人搞一个。由于目前还没有才能运用视觉模型,所以还是以文字模型为基础来操作阅读器。

为了让 Sandbox 愈加纯粹,Sandbox 只启动 Chrome,并且暴露 CDP 和 VNC 让 Server 来操作。

启动 Browser

坑点一:启动参数

Chrome browser 的启动有很多参数,遇到成绩再找参数有点费时费力,直接站在巨人的肩膀上,参考 browser-use 的启动参数:https://github.com/browser-use/browser-use/blob/main/browser_use/browser/chrome.py。

坑点二:CDP 监听地址不支持0.0.0.0

新版本 Chrome 似乎曾经不支持--remote-debugging-address参数(参考:https://issues.chromium.org/issues/327558594),处理方案可以经过端口转发:
# 假设 CDP 监听在 127.0.0.1:8222
socat TCP-LISTEN:9222,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:8222
VNC 访问

由于 Docker 镜像内没有 X Server 等图形环境,一切经过虚拟 X11 显示服务器Xvfb来给 Chrome 绘制窗口,并经过x11vnc提供 VNC Server:
# 启动 Xvfb 在 Display :1
Xvfb :1 -screen 0 1280x1029x24

# Chrome 阅读器指定 Display
google-chrome \
    --display=:1 \
    ...

# 启动 VNC 服务
x11vnc -display :1 -nopw -listen 0.0.0.0 -xkb -forever -rfbport 5900
由于 VNC 的四层端口对反向代理转发不敌对,所以这里还运用websockify将 VNC 转成七层Websocket:
# 将暴露 5901 端口 Websocket 服务
websockify 0.0.0.0:5901 localhost:5900
以便于后面NoVNC衔接。

AI 网页元素操作与信息提取

一末尾天真地把整个 html 丢给大模型,发现是行不通的,一丢过去就爆 Token 了。小调研了一下,发现如今主流的做法是:1)可交互元素提取;2)网页信息提取。

首先是提取可见的、可交互的元素,以便让大模型辨认哪些可以输入、点击等。普通将元素提取成index <tag>text</tag>,例如:
1 <input>手机号</input>
2 <input>密码</input>
3 <button>确认</button>
...
并在原标签中把 ID 号标注上去,这里是 Cursor 给我生成的代码,我也没有细看,但它能 work:
const interactiveElements = [];
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;

// Get all potentially relevant interactive elements
const elements = document.querySelectorAll('button, a, input, textarea, select, [role="button"], [tabindex]:not([tabindex="-1"])');

let validElementIndex = 0; // For generating consecutive indices

for (let i = 0; i < elements.length; i++) {
    const element = elements;
    // Check if the element is in the viewport and visible
    const rect = element.getBoundingClientRect();

    // Element must have some dimensions
    if (rect.width === 0 || rect.height === 0) continue;

    // Element must be within the viewport
    if (
        rect.bottom < 0 ||
        rect.top > viewportHeight ||
        rect.right < 0 ||
        rect.left > viewportWidth
    ) continue;

    // Check if the element is visible (not hidden by CSS)
    const style = window.getComputedStyle(element);
    if (
        style.display === 'none' ||
        style.visibility === 'hidden' ||
        style.opacity === '0'
    ) continue;

    // Get element type and text
    let tagName = element.tagName.toLowerCase();
    let text = '';

    if (element.value && ['input', 'textarea', 'select'].includes(tagName)) {
        text = element.value;

        // Add label and placeholder information for input elements
        if (tagName === 'input') {
            // Get associated label text
            let labelText = '';
            if (element.id) {
                const label = document.querySelector(`label[for="${element.id}"]`);
                if (label) {
                    labelText = label.innerText.trim();
                }
            }

            // Look for parent or sibling label
            if (!labelText) {
                const parentLabel = element.closest('label');
                if (parentLabel) {
                    labelText = parentLabel.innerText.trim().replace(element.value, '').trim();
                }
            }

            // Add label information
            if (labelText) {
                text = `[Label: ${labelText}] ${text}`;
            }

            // Add placeholder information
            if (element.placeholder) {
                text = `${text} [Placeholder: ${element.placeholder}]`;
            }
        }
    } else if (element.innerText) {
        text = element.innerText.trim().replace(/\\s+/g, ' ');
    } else if (element.alt) { // For image buttons
        text = element.alt;
    } else if (element.title) { // For elements with title
        text = element.title;
    } else if (element.placeholder) { // For placeholder text
        text = `[Placeholder: ${element.placeholder}]`;
    } else if (element.type) { // For input type
        text = `[${element.type}]`;

        // Add label and placeholder information for text-less input elements
        if (tagName === 'input') {
            // Get associated label text
            let labelText = '';
            if (element.id) {
                const label = document.querySelector(`label[for="${element.id}"]`);
                if (label) {
                    labelText = label.innerText.trim();
                }
            }

            // Look for parent or sibling label
            if (!labelText) {
                const parentLabel = element.closest('label');
                if (parentLabel) {
                    labelText = parentLabel.innerText.trim();
                }
            }

            // Add label information
            if (labelText) {
                text = `[Label: ${labelText}] ${text}`;
            }

            // Add placeholder information
            if (element.placeholder) {
                text = `${text} [Placeholder: ${element.placeholder}]`;
            }
        }
    } else {
        text = '[No text]';
    }

    // Maximum limit on text length to keep it clear
    if (text.length > 100) {
        text = text.substring(0, 97) + '...';
    }

    // Only add data-manus-id attribute to elements that meet the conditions
    element.setAttribute('data-manus-id', `manus-element-${validElementIndex}`);

    // Build selector - using only data-manus-id
    const selector = `[data-manus-id="manus-element-${validElementIndex}"]`;

    // Add element information to the array
    interactiveElements.push({
        index: validElementIndex,  // Use consecutive index
        tag: tagName,
        text: text,
        selector: selector
    });

    validElementIndex++; // Increment valid element counter
}

return interactiveElements;
这样大模型就可以根据 ID 号来操作元素了。

还要停止网页信息提取,目前主流做法是先去掉不可见元素后,先转成 markdown,再给大模型停止提取,以节省 Token,如下:
# Convert to Markdown
markdown_content=markdownify(visible_content)

max_content_length=min(50000,len(markdown_content))
response=awaitself.llm.ask([{
"role":"system",
"content":"You are a professional web page information extraction assistant. Please extract all information from the current page content and convert it to Markdown format."
},
{
"role":"user",
"content":markdown_content[:max_content_length]
}
])
至此,大模型就可以与网页交互与阅读网页信息内容了。

05

Web UI 设计

Web UI 编写虽然属于我的软肋,但属于 Cursor 的强项,结合对正版 Manus 的自创也可以搞得七七八八,页面比较简单。

06

如何部署?
环境要求

本项目次要依赖 Docker 停止开发与部署,需求安装较新版本的 Docker:

模型才能也是要求比较高:

引荐 Deepseek 与 ChatGPT。

部署

引荐运用 Docker Compose 停止部署:
services:
  frontend:
    image:simpleyyt/manus-frontend

    ports:
      - "5173:80"
    depends_on:
        -backend
    restart: unless-stopped

  networks:      

              - manus-network    environment:
     -BACKEND_URL=http://backend:8000

backend:
  image:simpleyyt/manus-backend
  depends_on:
-    -sandbox
  restart: unless-stopped
  volumes:
-     -/var/run/docker.sock:/var/run/docker.sock:ro
  networks:
-     -manus-network
  environment:
    # OpenAI API base URL
    -API_BASE=https://api.openai.com/v1
    # OpenAI API key, replace with your own
    -API_KEY=sk-xxxx
    # LLM model name
       -MODEL_NAME=gpt-4o
    # LLM temperature parameter, controls randomness
    -TEMPERATURE=0.7
    # Maximum tokens for LLM response
    -MAX_TOKENS=2000
    # Google Search API key for web search capability
    #- GOOGLE_SEARCH_API_KEY=
    # Google Custom Search Engine ID
    #- GOOGLE_SEARCH_ENGINE_ID=
    # Application log level
    -LOG_LEVEL=INFO
    # Docker image used for the sandbox
    -SANDBOX_IMAGE=simpleyyt/manus-sandbox
    # Prefix for sandbox container names
    -SANDBOX_NAME_PREFIX=sandbox
    # Time-to-live for sandbox containers in minutes
       -SANDBOX_TTL_MINUTES=30
    # Docker network for sandbox containers
    -SANDBOX_NETWORK=manus-network

sandbox:
image:simpleyyt/manus=sandbox
  command:    /bin/sh -c "exit 0"  # prevent sandbox from starting, ensure image is pulled
  restart:"no"
  networks:
- manus-network

networks:
  manus-network:
name:manus=network   
   driver:bridge  
保存成 docker-compose.yml 文件,并运转:
docker compose up -d留意:假如提示 sandbox-1 exited with code 0,这是正常的,这是为了让 sandbox 镜像成功拉取到本地。
打开阅读器访问 http://localhost:5173 即可访问 Manus。

07

如何开发?
环境预备

环境要求在部署章节曾经做了阐明。

下载项目:
git clone https://github.com/Simpleyyt/ai-manus.git
cd ai-manus复制配置文件:cp .env.example .env
修正配置文件:
# Model provider configuration
API_KEY=
API_BASE=https://api.openai.com/v1

# Model configuration
MODEL_NAME=gpt-4o
TEMPERATURE=0.7
MAX_TOKENS=2000

# Optional: Google search configuration
#GOOGLE_SEARCH_API_KEY=
#GOOGLE_SEARCH_ENGINE_ID=

# Sandbox configuration
SANDBOX_IMAGE=simpleyyt/sandbox
SANDBOX_NAME_PREFIX=sandbox
SANDBOX_TTL_MINUTES=30
SANDBOX_NETWORK=manus-network

# Log configuration
LOG_LEVEL=INFO开发

开发形式下只会全局启动一个沙盒。
运转调试:
# 相当于 docker compose -f docker-compose-development.yaml up
./dev.sh up
Web、Sandbox、Server 都会以 reload 形式运转,即代码改动会自动 reload。暴露的端口如下:

当依赖变化时,即requirements.txt或者package.json变化时,可以清算并重新构建一下:
# 清算掉一切相关资源
./dev.sh down -v

# 重新构建镜像
./dev.sh build

# 调试运转
./dev.sh upexportIMAGE_REGISTRY=your-registry-url
exportIMAGE_TAG=latest

# 构建镜像
./run build

# 推送到相应的镜像仓库
./run push
08

写在最后

本项目次要用于学习与研讨目的,共同窗习和提高,也是代码工程师将来跃变提词工程师做点预备。

END

点击下方名片,即刻关注我们




欢迎光临 职贝云数AI新零售门户 (https://www.taojin168.com/cloud/) Powered by Discuz! X3.5