diff --git a/.env.example b/.env.example index 93d25ba..38392cf 100644 --- a/.env.example +++ b/.env.example @@ -6,17 +6,4 @@ APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password # 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。 DASHSCOPE_API_KEY= -# 非敏感运行参数。 -POSTGRES_DB=smart_factory_agent -POSTGRES_USER=smart_factory -APP_ADMIN_USERNAME=admin -APP_RUN_TIMEOUT=60m -SESSION_COOKIE_SECURE=false -MANUAGENT_DATA_ROOT=/srv/manuagent/data -# 默认值即部署要求的 0.0.0.0:5173;普通 Linux 部署无需修改。 -FRONTEND_PORT=5173 - -# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。 -AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0 -BACKEND_IMAGE=manuagent-backend:0.1.0 -FRONTEND_IMAGE=manuagent-frontend:0.1.0 +# 镜像、数据库名称、端口和数据目录等普通配置直接修改 docker-compose.yml。 diff --git a/.gitignore b/.gitignore index b1c0dfc..98989f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -server/target/ -web-ui/node_modules/ -web-ui/dist/ +server/**/target/ +web-ui/**/node_modules/ +web-ui/**/dist/ sandbox/node_modules/ *.tsbuildinfo data/ @@ -18,4 +18,4 @@ deepseek_key.txt test_data/ # 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。 -deploy/*.tar +/agent-sandbox-*.tar diff --git a/README.md b/README.md index 59f8021..6df3a7d 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,53 @@ ## 技术栈 -- Spring Boot 3.5.16、JDK 21、AgentScope Java 2.0.1、AG-UI +- Spring Boot 4.1.1、JDK 25、AgentScope Java 2.0.1、AG-UI +- MyBatis-Flex 1.11.8(Boot 4 starter)、业务与 HTTP 使用 Jackson 3;AgentScope 内部保留 Jackson 2 - Vue 3.5、Vite、Element Plus、Vue Element Plus X - PostgreSQL 17、Flyway ## 本地启动 +需要 JDK 25、Maven、Node.js 22 和 Docker。先设置 `JAVA_HOME` 指向 JDK 25;IDEA 从 +`server/pom.xml` 导入 Maven reactor,项目 SDK 与 Maven runner 均选 JDK 25,运行 +`manuagent-web` 的 `ManuAgentApplication`,工作目录设为仓库根目录。 +IDEA 运行配置同时设置 `APP_DATA_ROOT` 为既有数据目录的绝对路径,以及 +`APP_DASHSCOPE_KEY_FILE` 为知识库密钥文件的绝对路径,与下面的命令行启动配置保持一致。 + +后端通过 `SPRING_DATASOURCE_URL`、`SPRING_DATASOURCE_USERNAME`、`SPRING_DATASOURCE_PASSWORD` +连接可用的 PostgreSQL 17。根 Compose 的数据库端口只供容器内部使用;宿主机开发需使用已发布 +端口的本地数据库,或在本地 Compose override 中增加 PostgreSQL 端口映射。 + ```bash -docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox -docker compose up -d postgres -mvn -q -f server/pom.xml spring-boot:run -npm --prefix web-ui install +docker build -t agent-sandbox:0.1 -f sandbox/Dockerfile sandbox +mvn -f server/pom.xml clean package +export APP_DATA_ROOT="$PWD/data" +export APP_DASHSCOPE_KEY_FILE="$PWD/dashscope_key.txt" +java -jar server/manuagent-web/target/manuagent-web.jar +# 在另一个终端启动前端;依赖只从 workspace 根目录安装。 +npm --prefix web-ui ci npm --prefix web-ui run dev ``` -本地开发服务器默认使用 Vite 配置的端口。容器化部署固定从 访问。 +本地开发入口为 ,代理后端 8080。唯一可执行产物是 +`server/manuagent-web/target/manuagent-web.jar`。上述命令从仓库根目录执行,将 `APP_DATA_ROOT` 设为 +根目录下 `data` 的绝对路径;已有数据时改为对应绝对路径,避免移动启动目录后找不到历史文件。 +容器化部署固定从 访问。 + +Agent 会话状态保存在 `APP_DATA_ROOT/agent-state`,与项目文件和沙箱快照一起持久化。 +启动时,会为数据目录内的已有项目从当前运行用户的 `~/.agentscope/state`(或 JVM `agentscope.state.home` +指定目录)分别复制会话与沙箱状态,补齐此前只迁移会话的项目;保留旧目录,不覆盖已有新状态。 +跨机器或从旧容器升级时,须先将旧状态目录复制到新环境的对应位置,并保留快照原有绝对路径; +仅保留项目文件不能恢复完整会话。旧进程停止后再迁移,避免继续写入旧位置。 + +沙箱镜像统一为 `agent-sandbox:0.1`。升级前停止旧后端并确认旧沙箱容器已退出;历史会话恢复时 +会将旧 `smart-factory-agent-runtime` 镜像引用迁移到当前 `APP_SANDBOX_IMAGE` 配置,保留会话和快照。 + +`APP_RUN_TIMEOUT` 限制整个 Run(含重连与结果修复)的总时长,默认 60 分钟;结构化结果最多修复 +3 次。`APP_MAX_CONCURRENT_RUNS` 限制单个后端同时运行的任务数,默认 2,新增任务满额时返回 429。 +同项目的模型切换、停止后继续及阶段接续共用原运行名额,等待旧任务释放资源、保存快照后再启动。 +修改沙箱文件后须重建 `sandbox/Dockerfile` 镜像;其中 `docx-example.cjs` 提供与已安装依赖 +一致的表格、原生批注示例及可执行验证。 模型连接只能在“模型配置”页面新增并持久化到 PostgreSQL;API Key 会使用 `APP_MASTER_KEY` 环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。 @@ -31,23 +63,23 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>' ## Docker Compose 部署 -部署包含 Nginx 前端、Spring Boot 后端、Agent Runtime 镜像和 PostgreSQL。Agent Runtime -不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `agent-runtime` 服务只校验 +部署包含 Nginx 前端、Spring Boot 后端、沙箱镜像和 PostgreSQL。沙箱 +不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `sandbox` 服务只校验 镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。 -1. 在开发机构建并导出 Agent Runtime 镜像: +1. 在开发机构建并导出沙箱镜像: ```bash - docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox - docker save -o smart-factory-agent-runtime-0.1.0.tar smart-factory-agent-runtime:0.1.0 + docker build -t agent-sandbox:0.1 -f sandbox/Dockerfile sandbox + docker save -o agent-sandbox-0.1.tar agent-sandbox:0.1 ``` 将 Tar 文件传到服务器后导入。Compose 设置了 `pull_policy: never`,若镜像不存在会直接报错, 不会在服务器自动拉取或构建: ```bash - docker load -i smart-factory-agent-runtime-0.1.0.tar - docker image inspect smart-factory-agent-runtime:0.1.0 + docker load -i agent-sandbox-0.1.tar + docker image inspect agent-sandbox:0.1 ``` 2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。 @@ -56,9 +88,9 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>' cp .env.example .env ``` -3. 确认 `MANUAGENT_DATA_ROOT` 是 Docker 宿主机上的绝对路径。这个路径会以完全相同的路径挂载到 - 后端容器,供动态 Agent Runtime 继续挂载项目材料、工作目录和产物。Linux 默认值为 - `/srv/manuagent/data`。 +3. Compose 中的镜像、数据库名称、端口和数据目录均使用静态值;调整时直接修改根目录的 `docker-compose.yml`。 + 数据目录固定为 `/srv/manuagent/data`;修改时须同步更新后端的 `APP_DATA_ROOT` 和对应挂载的 + `source`、`target`,保持容器内外使用同一个宿主机绝对路径,供动态沙箱挂载项目材料和产物。 4. 构建前后端并启动完整服务: @@ -67,7 +99,11 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>' docker compose -f docker-compose.yml ps ``` -5. 打开 ,使用 `.env` 中的 `APP_ADMIN_USERNAME` 和 + 根目录的 `docker-compose.yml` 是唯一部署入口。后端镜像使用 Maven 3.9.13 / Temurin 25.0.2 + 构建和 Temurin 25.0.2 JRE 运行;前端镜像从 + `web-ui/apps/web/dist` 复制静态文件。 + +5. 打开 ,使用 Compose 中的管理员账号 `admin` 和 `.env` 中的 `APP_ADMIN_PASSWORD` 登录。查看日志或停止服务: ```bash @@ -75,22 +111,42 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>' docker compose -f docker-compose.yml down ``` -默认端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173,可仅在本机 -验证时临时执行 `$env:FRONTEND_PORT = '15173'`;Linux 服务器部署应保留默认的 5173。 +端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173,可在本机验证时 +将 Compose 的端口映射临时改为 `0.0.0.0:15173:80`;Linux 服务器部署应保留 5173。 PostgreSQL 数据保存在 `postgres-data` 命名卷中;项目材料、Agent 工作区、快照和生成文件保存在 -`MANUAGENT_DATA_ROOT`。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除 +Compose 配置的数据目录。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除 PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的 Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将 -`SESSION_COOKIE_SECURE` 设为 `true`。 +Compose 中的 `SERVER_SERVLET_SESSION_COOKIE_SECURE` 设为字符串 `"true"`。 ## 验证 ```bash -mvn -q -f server/pom.xml test -npm --prefix web-ui run test +mvn -f server/pom.xml verify +npm --prefix web-ui test npm --prefix web-ui run build docker compose -f docker-compose.yml config --quiet ``` -产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。 +以上命令分别验证后端测试/打包、前端测试、四个 workspace 的类型检查及单应用构建、Compose 配置。 +模块依赖边界仍需在代码审查中核对,当前没有自动边界检查命令。 + +## 模块边界 + +| 后端模块 | 前端 workspace | 职责 | +| --- | --- | --- | +| `manuagent-common` | `packages/common` | 错误、TypeHandler;前端共享请求、CSRF、基础样式 | +| `manuagent-admin` | `packages/admin` | 用户服务与用户持久化;登录页面 | +| `manuagent-agent` | `packages/agent` | 项目、文件、模型、Skill、Agent 执行、规划与产物;业务页面和侧栏 | +| `manuagent-web` | `apps/web` | HTTP、认证上下文、基础设施装配与启动;单应用壳和路由 | + +Agent 和 Admin 只依赖 Common,Web 装配三者。仍是一个后端进程、一个数据源和一个事务体系。 +Controller 从登录身份解析用户 UUID,业务服务负责事务及审计字段。`AdminProperties` 和 +`AgentProperties` 继续绑定原有 `app.*` 键,数据库配置和环境变量名保持兼容。 + +历史 Flyway 文件保留在 `server/src/main/resources/db/migration`,由 Web 原样打包一份; +请勿迁移、重命名或改写这些已共享脚本。Agent 的 Mapper XML 和提示词随普通库 JAR 加载。 +前端只有根目录一份 `package-lock.json`,业务包通过 `@manuagent/*` 的公开 exports 引用。 + +本次改造的证据与验收边界见 [MANU-1 实现与验收](docs/MANU-1-implementation.md)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。 diff --git a/deploy/.env.example b/deploy/.env.example deleted file mode 100644 index 93d25ba..0000000 --- a/deploy/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# 复制为 .env 后必须替换以下三个敏感值;不要把真实 .env 提交到 Git。 -POSTGRES_PASSWORD=replace-with-a-strong-database-password -APP_MASTER_KEY=replace-with-at-least-32-random-characters -APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password - -# 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。 -DASHSCOPE_API_KEY= - -# 非敏感运行参数。 -POSTGRES_DB=smart_factory_agent -POSTGRES_USER=smart_factory -APP_ADMIN_USERNAME=admin -APP_RUN_TIMEOUT=60m -SESSION_COOKIE_SECURE=false -MANUAGENT_DATA_ROOT=/srv/manuagent/data -# 默认值即部署要求的 0.0.0.0:5173;普通 Linux 部署无需修改。 -FRONTEND_PORT=5173 - -# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。 -AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0 -BACKEND_IMAGE=manuagent-backend:0.1.0 -FRONTEND_IMAGE=manuagent-frontend:0.1.0 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index b29b64c..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: manuagent - -services: - postgres: - image: postgres:17-alpine - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent} - POSTGRES_USER: ${POSTGRES_USER:-smart_factory} - POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password - secrets: - - postgres_password - volumes: - - postgres-data:/var/lib/postgresql/data - networks: - - manuagent-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 20 - start_period: 10s - - # 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。 - # Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。 - # 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。 - agent-runtime: - image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0} - pull_policy: never - command: ["/bin/true"] - restart: "no" - networks: - - manuagent-network - - backend: - image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0} - build: - context: ./server - dockerfile: Dockerfile - restart: unless-stopped - init: true - depends_on: - postgres: - condition: service_healthy - restart: true - agent-runtime: - condition: service_completed_successfully - environment: - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent} - SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory} - # Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。 - SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/ - APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} - APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key - APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin} - APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0} - APP_SANDBOX_NETWORK: manuagent-network - APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m} - SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} - secrets: - - source: postgres_password - target: spring.datasource.password - - source: app_master_key - target: app.master-key - - source: admin_password - target: app.admin-password - - source: dashscope_api_key - target: dashscope_key - volumes: - # DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。 - - type: bind - source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} - target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} - # AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。 - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - expose: - - "8080" - networks: - - manuagent-network - healthcheck: - test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/api/auth/csrf"] - interval: 10s - timeout: 5s - retries: 18 - start_period: 30s - stop_grace_period: 30s - - frontend: - image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0} - build: - context: ./web-ui - dockerfile: Dockerfile - restart: unless-stopped - init: true - depends_on: - backend: - condition: service_healthy - restart: true - ports: - # 默认严格监听 0.0.0.0:5173;仅当宿主机保留该端口时,才通过 FRONTEND_PORT 临时覆盖。 - - "0.0.0.0:${FRONTEND_PORT:-5173}:80" - networks: - - manuagent-network - healthcheck: - test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/"] - interval: 10s - timeout: 5s - retries: 6 - start_period: 10s - -networks: - manuagent-network: - # 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。 - name: manuagent-network - driver: bridge - -volumes: - postgres-data: - -secrets: - postgres_password: - environment: POSTGRES_PASSWORD - app_master_key: - environment: APP_MASTER_KEY - admin_password: - environment: APP_ADMIN_PASSWORD - dashscope_api_key: - environment: DASHSCOPE_API_KEY diff --git a/docker-compose.yml b/docker-compose.yml index b29b64c..3421a9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,8 @@ services: image: postgres:17-alpine restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent} - POSTGRES_USER: ${POSTGRES_USER:-smart_factory} + POSTGRES_DB: smart_factory_agent + POSTGRES_USER: smart_factory POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password secrets: - postgres_password @@ -15,17 +15,17 @@ services: networks: - manuagent-network healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + test: ["CMD", "pg_isready", "-U", "smart_factory", "-d", "smart_factory_agent"] interval: 5s timeout: 5s retries: 20 start_period: 10s - # 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。 - # Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。 - # 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。 - agent-runtime: - image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0} + # 此服务负责校验并登记 AgentScope 使用的沙箱镜像。 + # 沙箱镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。 + # 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的沙箱容器。 + sandbox: + image: agent-sandbox:0.1 pull_policy: never command: ["/bin/true"] restart: "no" @@ -33,7 +33,7 @@ services: - manuagent-network backend: - image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0} + image: manuagent-backend:0.1.0 build: context: ./server dockerfile: Dockerfile @@ -43,20 +43,21 @@ services: postgres: condition: service_healthy restart: true - agent-runtime: + sandbox: condition: service_completed_successfully environment: - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent} - SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/smart_factory_agent + SPRING_DATASOURCE_USERNAME: smart_factory # Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。 SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/ - APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} + APP_DATA_ROOT: /srv/manuagent/data APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key - APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin} - APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0} + APP_ADMIN_USERNAME: admin + APP_SANDBOX_IMAGE: agent-sandbox:0.1 APP_SANDBOX_NETWORK: manuagent-network - APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m} - SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + APP_RUN_TIMEOUT: 60m + APP_MAX_CONCURRENT_RUNS: "2" + SERVER_SERVLET_SESSION_COOKIE_SECURE: "false" secrets: - source: postgres_password target: spring.datasource.password @@ -69,9 +70,9 @@ services: volumes: # DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。 - type: bind - source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} - target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} - # AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。 + source: /srv/manuagent/data + target: /srv/manuagent/data + # AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的沙箱容器。 - type: bind source: /var/run/docker.sock target: /var/run/docker.sock @@ -88,7 +89,7 @@ services: stop_grace_period: 30s frontend: - image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0} + image: manuagent-frontend:0.1.0 build: context: ./web-ui dockerfile: Dockerfile @@ -99,8 +100,8 @@ services: condition: service_healthy restart: true ports: - # 默认严格监听 0.0.0.0:5173;仅当宿主机保留该端口时,才通过 FRONTEND_PORT 临时覆盖。 - - "0.0.0.0:${FRONTEND_PORT:-5173}:80" + # 固定监听 0.0.0.0:5173;需要调整端口时直接修改此映射。 + - "0.0.0.0:5173:80" networks: - manuagent-network healthcheck: @@ -112,7 +113,7 @@ services: networks: manuagent-network: - # 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。 + # 固定网络名,确保后端动态创建的沙箱容器可以加入同一个网络。 name: manuagent-network driver: bridge diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index 9f818a4..cba0ca4 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -6,12 +6,12 @@ COPY --from=node /usr/local /usr/local ENV JAVA_HOME=/opt/java/openjdk \ PATH=/opt/java/openjdk/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin \ - NODE_PATH=/opt/agent-runtime/node_modules \ - PLAYWRIGHT_BROWSERS_PATH=/opt/agent-runtime/ms-playwright \ + NODE_PATH=/opt/sandbox/node_modules \ + PLAYWRIGHT_BROWSERS_PATH=/opt/sandbox/ms-playwright \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -WORKDIR /opt/agent-runtime +WORKDIR /opt/sandbox COPY package.json package-lock.json ./ RUN npm ci --omit=dev \ && pip install --no-cache-dir \ @@ -34,7 +34,7 @@ RUN ln -s /opt/java/openjdk/bin/java /usr/local/bin/java \ COPY fonts-local.conf /etc/fonts/local.conf RUN fc-cache -f -COPY --chown=agent:agent document_view.py ./ +COPY --chown=agent:agent document_view.py docx-example.cjs ./ USER agent WORKDIR /workspace diff --git a/sandbox/docx-example.cjs b/sandbox/docx-example.cjs new file mode 100644 index 0000000..14c7e5c --- /dev/null +++ b/sandbox/docx-example.cjs @@ -0,0 +1,30 @@ +// 使用 Runtime 实际安装的 docx API;可作为表格与原生批注的最小验证脚本。 +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { Document, Paragraph, TextRun, Table, TableRow, TableCell, Packer, + CommentRangeStart, CommentRangeEnd, CommentReference } = require('docx'); + +async function main() { + const output = process.argv[2]; + assert(output, '用法:node docx-example.cjs <输出.docx>'); + const document = new Document({ + comments: { children: [{ id: 0, author: '材料评审', date: new Date(), + children: [new Paragraph({ children: [new TextRun('请企业确认设备数量。')] })] }] }, + sections: [{ children: [ + new Paragraph({ children: [ + new CommentRangeStart(0), new TextRun('待企业提供设备数量。'), + new CommentRangeEnd(0), new TextRun({ children: [new CommentReference(0)] }) + ] }), + new Table({ rows: [new TableRow({ children: ['项目', '待确认'].map(text => + new TableCell({ children: [new Paragraph({ children: [new TextRun(text)] })] })) })] }) + ] }] + }); + const buffer = await Packer.toBuffer(document); + assert.equal(buffer.subarray(0, 2).toString(), 'PK'); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, buffer); + console.log(`DOCX API 验证通过:${buffer.length} 字节`); +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/sandbox/package-lock.json b/sandbox/package-lock.json index d8586cc..df82b15 100644 --- a/sandbox/package-lock.json +++ b/sandbox/package-lock.json @@ -1,11 +1,11 @@ { - "name": "smart-factory-agent-runtime", + "name": "agent-sandbox", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "smart-factory-agent-runtime", + "name": "agent-sandbox", "version": "0.1.0", "dependencies": { "docx": "^9.5.1", diff --git a/sandbox/package.json b/sandbox/package.json index 03b9b39..067e704 100644 --- a/sandbox/package.json +++ b/sandbox/package.json @@ -1,5 +1,5 @@ { - "name": "smart-factory-agent-runtime", + "name": "agent-sandbox", "private": true, "version": "0.1.0", "dependencies": { diff --git a/server/.dockerignore b/server/.dockerignore index 8861ff7..14921c0 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,4 +1,4 @@ -target +**/target .idea *.iml *.log diff --git a/server/Dockerfile b/server/Dockerfile index cbd88b5..50aeb9d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,18 +1,26 @@ # syntax=docker/dockerfile:1 -# Maven 构建阶段使用与项目一致的 JDK 21,并利用 BuildKit 缓存减少重复下载依赖的时间。 -FROM maven:3.9.13-eclipse-temurin-21 AS builder +# Maven 构建阶段使用与项目一致的 JDK 25,并利用 BuildKit 缓存减少重复下载依赖的时间。 +FROM maven:3.9.13-eclipse-temurin-25@sha256:ade3c87e3cdfbe04932afa16b31814cbf60b0122d21d78a76530684a1eeb7cc2 AS builder WORKDIR /workspace COPY pom.xml ./ -COPY src ./src +COPY manuagent-common/pom.xml manuagent-common/pom.xml +COPY manuagent-admin/pom.xml manuagent-admin/pom.xml +COPY manuagent-agent/pom.xml manuagent-agent/pom.xml +COPY manuagent-web/pom.xml manuagent-web/pom.xml +COPY src/main/resources/db ./src/main/resources/db +COPY manuagent-common/src ./manuagent-common/src +COPY manuagent-admin/src ./manuagent-admin/src +COPY manuagent-agent/src ./manuagent-agent/src +COPY manuagent-web/src ./manuagent-web/src # 直接打包只解析项目真正需要的依赖;独立 go-offline 会额外下载大量未参与构建的报告插件。 RUN --mount=type=cache,target=/root/.m2 mvn -B -DskipTests package # AgentScope DockerSandbox 通过 docker 命令创建 Runtime,直接复用官方镜像中的 CLI 二进制。 FROM docker:29-cli AS docker-cli -FROM eclipse-temurin:21-jre-jammy +FROM eclipse-temurin:25.0.2_10-jre-jammy # curl 用于容器健康检查;gosu 用于完成目录和 Docker Socket 权限初始化后降权运行 Java。 RUN apt-get update \ @@ -27,7 +35,7 @@ RUN groupadd --gid 10001 manuagent \ && chown -R manuagent:manuagent /opt/manuagent /srv/manuagent WORKDIR /opt/manuagent -COPY --from=builder /workspace/target/*.jar app.jar +COPY --from=builder /workspace/manuagent-web/target/manuagent-web.jar app.jar COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh diff --git a/server/docker-entrypoint.sh b/server/docker-entrypoint.sh index 9d68b7d..15b5c5b 100644 --- a/server/docker-entrypoint.sh +++ b/server/docker-entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -# Agent Runtime 的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。 +# 沙箱的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。 data_root="${APP_DATA_ROOT:-/srv/manuagent/data}" mkdir -p "$data_root" chown manuagent:manuagent "$data_root" @@ -10,7 +10,7 @@ chown manuagent:manuagent "$data_root" # 启动时读取真实组 ID 并把低权限应用用户加入对应组,避免以 root 身份运行 Spring Boot。 docker_socket="/var/run/docker.sock" if [ ! -S "$docker_socket" ]; then - echo "错误:未挂载 $docker_socket,后端无法创建 Agent Runtime 容器。" >&2 + echo "错误:未挂载 $docker_socket,后端无法创建沙箱容器。" >&2 exit 1 fi diff --git a/server/manuagent-admin/pom.xml b/server/manuagent-admin/pom.xml new file mode 100644 index 0000000..a071083 --- /dev/null +++ b/server/manuagent-admin/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + tech.easyflow + manuagent-parent + 0.1.0 + + manuagent-admin + + + tech.easyflow + manuagent-common + + + com.mybatis-flex + mybatis-flex-core + + + org.springframework.boot + spring-boot + + + org.springframework + spring-context + + + org.springframework + spring-web + + + org.springframework.security + spring-security-core + + + org.springframework.security + spring-security-crypto + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserEntity.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java rename to server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserEntity.java index bcbd5ce..b40dd17 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java +++ b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.admin.auth; import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.KeyType; @@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Column; import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.app_user} 表的管理员用户实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserMapper.java similarity index 71% rename from server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java rename to server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserMapper.java index 8fde491..ee1008e 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java +++ b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/AppUserMapper.java @@ -1,7 +1,7 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.admin.auth; import com.mybatisflex.core.BaseMapper; -import tech.easyflow.manuagent.entity.AppUserEntity; +import tech.easyflow.manuagent.admin.auth.AppUserEntity; /** * 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力。 @@ -9,5 +9,6 @@ import tech.easyflow.manuagent.entity.AppUserEntity; *

用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper, * 不额外维护 Mapper XML。

*/ +@org.apache.ibatis.annotations.Mapper public interface AppUserMapper extends BaseMapper { } diff --git a/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/UserService.java similarity index 85% rename from server/src/main/java/tech/easyflow/manuagent/auth/UserService.java rename to server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/UserService.java index 6a6f5ee..97d11ba 100644 --- a/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java +++ b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/auth/UserService.java @@ -1,10 +1,7 @@ -package tech.easyflow.manuagent.auth; +package tech.easyflow.manuagent.admin.auth; import com.mybatisflex.core.query.QueryWrapper; import java.util.UUID; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -13,20 +10,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.AppUserEntity; -import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.admin.config.AdminProperties; +import tech.easyflow.manuagent.admin.auth.AppUserEntity; +import tech.easyflow.manuagent.admin.auth.AppUserMapper; /** * 管理单管理员账户和当前用户标识。 */ @Service -@Order(1) -public class UserService implements UserDetailsService, ApplicationRunner { +public class UserService implements UserDetailsService { private final AppUserMapper userMapper; private final PasswordEncoder passwordEncoder; - private final AppProperties properties; + private final AdminProperties properties; /** * 创建用户服务。 @@ -35,7 +31,7 @@ public class UserService implements UserDetailsService, ApplicationRunner { * @param passwordEncoder 密码编码器 * @param properties 应用配置 */ - public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) { + public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AdminProperties properties) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; this.properties = properties; @@ -44,10 +40,8 @@ public class UserService implements UserDetailsService, ApplicationRunner { /** * 初始化本地管理员账户。 * - * @param args 启动参数 */ - @Override - public void run(ApplicationArguments args) { + public void initializeAdministrator() { long count = userMapper.selectCountByQuery(QueryWrapper.create()); if (count > 0) { return; diff --git a/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/config/AdminProperties.java b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/config/AdminProperties.java new file mode 100644 index 0000000..cda8c20 --- /dev/null +++ b/server/manuagent-admin/src/main/java/tech/easyflow/manuagent/admin/config/AdminProperties.java @@ -0,0 +1,8 @@ +package tech.easyflow.manuagent.admin.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** 首次启动时的管理员账号配置。 */ +@ConfigurationProperties(prefix = "app") +public record AdminProperties(String adminUsername, String adminPassword) { +} diff --git a/server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java b/server/manuagent-admin/src/test/java/tech/easyflow/manuagent/admin/auth/UserServiceTest.java similarity index 92% rename from server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java rename to server/manuagent-admin/src/test/java/tech/easyflow/manuagent/admin/auth/UserServiceTest.java index b4a38a5..eaf4758 100644 --- a/server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java +++ b/server/manuagent-admin/src/test/java/tech/easyflow/manuagent/admin/auth/UserServiceTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.auth; +package tech.easyflow.manuagent.admin.auth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -21,10 +21,10 @@ import org.mockito.ArgumentCaptor; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.AppUserEntity; -import tech.easyflow.manuagent.mapper.AppUserMapper; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.admin.config.AdminProperties; +import tech.easyflow.manuagent.admin.auth.AppUserEntity; +import tech.easyflow.manuagent.admin.auth.AppUserMapper; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义。 @@ -33,7 +33,7 @@ class UserServiceTest { private AppUserMapper mapper; private PasswordEncoder passwordEncoder; - private AppProperties properties; + private AdminProperties properties; private UserService service; /** @@ -52,7 +52,7 @@ class UserServiceTest { mapper = mock(AppUserMapper.class); passwordEncoder = mock(PasswordEncoder.class); - properties = mock(AppProperties.class); + properties = mock(AdminProperties.class); service = new UserService(mapper, passwordEncoder, properties); } @@ -66,7 +66,7 @@ class UserServiceTest { when(properties.adminPassword()).thenReturn("plain-password"); when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); - service.run(null); + service.initializeAdministrator(); ArgumentCaptor captor = ArgumentCaptor.forClass(AppUserEntity.class); verify(mapper).insertSelective(captor.capture()); @@ -84,7 +84,7 @@ class UserServiceTest { void shouldNotCreateAdministratorWhenAnyUserExists() { when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L); - service.run(null); + service.initializeAdministrator(); verify(mapper, never()).insertSelective(any(AppUserEntity.class)); } diff --git a/server/manuagent-agent/pom.xml b/server/manuagent-agent/pom.xml new file mode 100644 index 0000000..71954ad --- /dev/null +++ b/server/manuagent-agent/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + tech.easyflow + manuagent-parent + 0.1.0 + + manuagent-agent + + + tech.easyflow + manuagent-common + + + com.mybatis-flex + mybatis-flex-core + + + org.springframework.boot + spring-boot + + + org.springframework + spring-context + + + org.springframework + spring-web + + + org.springframework + spring-tx + + + jakarta.validation + jakarta.validation-api + + + org.slf4j + slf4j-api + + + tools.jackson.core + jackson-databind + + + io.projectreactor + reactor-core + + + org.apache.tika + tika-core + + + io.agentscope + agentscope-harness + + + io.agentscope + agentscope-extensions-model-openai + + + io.agentscope + agentscope-extensions-agui + + + io.agentscope + agentscope-extensions-skill-postgresql-repository + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-validation + test + + + diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactEntity.java similarity index 94% rename from server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactEntity.java index 03f587f..e47b757 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.artifact; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table; import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.artifact} 表的最终产物实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactMapper.java similarity index 77% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactMapper.java index 1c68b13..1228af0 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactMapper.java @@ -1,12 +1,13 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.artifact; import com.mybatisflex.core.BaseMapper; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.ArtifactEntity; +import tech.easyflow.manuagent.agent.artifact.ArtifactEntity; /** * 提供产物基础查询以及 PostgreSQL 原子 upsert 能力。 */ +@org.apache.ibatis.annotations.Mapper public interface ArtifactMapper extends BaseMapper { /** diff --git a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactService.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactService.java index c1c4165..5e2d8d7 100644 --- a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/ArtifactService.java @@ -1,11 +1,11 @@ -package tech.easyflow.manuagent.artifact; +package tech.easyflow.manuagent.agent.artifact; import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ArtifactEntity; -import tech.easyflow.manuagent.mapper.ArtifactMapper; -import tech.easyflow.manuagent.project.ProjectFileService; -import com.fasterxml.jackson.databind.JsonNode; +import tech.easyflow.manuagent.agent.artifact.ArtifactEntity; +import tech.easyflow.manuagent.agent.artifact.ArtifactMapper; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tools.jackson.databind.JsonNode; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -78,9 +78,9 @@ public class ArtifactService { } throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败"); } - com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject() - ? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy() - : com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode(); + tools.jackson.databind.node.ObjectNode enriched = metadata.isObject() + ? ((tools.jackson.databind.node.ObjectNode) metadata).deepCopy() + : tools.jackson.databind.node.JsonNodeFactory.instance.objectNode(); enriched.put("docxValidated", true); enriched.put("docxEntries", validation.entryCount()); enriched.put("commentCount", validation.commentCount()); diff --git a/server/src/main/java/tech/easyflow/manuagent/artifact/DocxValidator.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/DocxValidator.java similarity index 99% rename from server/src/main/java/tech/easyflow/manuagent/artifact/DocxValidator.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/DocxValidator.java index 9645510..cbcccbe 100644 --- a/server/src/main/java/tech/easyflow/manuagent/artifact/DocxValidator.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/artifact/DocxValidator.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.artifact; +package tech.easyflow.manuagent.agent.artifact; import tech.easyflow.manuagent.common.ApiException; import java.io.IOException; diff --git a/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/config/AgentProperties.java similarity index 74% rename from server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/config/AgentProperties.java index 195eebe..db75a04 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/config/AgentProperties.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.agent.config; import java.nio.file.Path; import java.time.Duration; @@ -10,19 +10,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @param dataRoot 项目材料、工作区和产物根目录 * @param dashscopeKeyFile 百炼 Key 文件 * @param masterKey 模型密钥加密主密钥 - * @param adminUsername 本地管理员用户名 - * @param adminPassword 本地管理员初始密码 * @param sandboxImage Agent Docker 运行镜像 * @param sandboxNetwork Agent Docker 网络 * @param runTimeout 单次 Agent 运行超时 */ @ConfigurationProperties(prefix = "app") -public record AppProperties( +public record AgentProperties( Path dataRoot, Path dashscopeKeyFile, String masterKey, - String adminUsername, - String adminPassword, String sandboxImage, String sandboxNetwork, Duration runTimeout) { diff --git a/server/src/main/java/tech/easyflow/manuagent/model/KeyCipher.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/KeyCipher.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/model/KeyCipher.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/KeyCipher.java index 3738a35..5af417c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/KeyCipher.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/KeyCipher.java @@ -1,7 +1,7 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.agent.model; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.agent.config.AgentProperties; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; @@ -31,7 +31,7 @@ public class KeyCipher { * * @param properties 应用配置 */ - public KeyCipher(AppProperties properties) { + public KeyCipher(AgentProperties properties) { try { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(properties.masterKey().getBytes(StandardCharsets.UTF_8)); diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentEntity.java similarity index 92% rename from server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentEntity.java index 455b271..2cda7d9 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.model; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType; import com.mybatisflex.annotation.Table; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentMapper.java similarity index 77% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentMapper.java index fdb3c90..a63c90f 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelAssignmentMapper.java @@ -1,12 +1,13 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.model; import com.mybatisflex.core.BaseMapper; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.ModelAssignmentEntity; +import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity; /** * 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert。 */ +@org.apache.ibatis.annotations.Mapper public interface ModelAssignmentMapper extends BaseMapper { /** 按角色插入或更新模型分配。 */ diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigEntity.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigEntity.java index a1421ee..14121c3 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.model; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; import org.apache.ibatis.type.JdbcType; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.model_config} 表的模型配置与加密密钥实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigMapper.java similarity index 91% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigMapper.java index f5b60cd..e35669c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelConfigMapper.java @@ -1,8 +1,8 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.model; import com.mybatisflex.core.BaseMapper; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.ModelConfigEntity; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; /** * 提供模型配置的 MyBatis-Flex CRUD 能力。 @@ -10,6 +10,7 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity; *

普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新 * 使用 XML 显式声明 TypeHandler,避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。

*/ +@org.apache.ibatis.annotations.Mapper public interface ModelConfigMapper extends BaseMapper { /** diff --git a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelService.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/model/ModelService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelService.java index d0ed79c..03168e5 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/model/ModelService.java @@ -1,21 +1,19 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.agent.model; import com.mybatisflex.core.query.QueryWrapper; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ModelAssignmentEntity; -import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.entity.AgentRunEntity; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; -import com.fasterxml.jackson.databind.ObjectMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; +import tech.easyflow.manuagent.agent.runtime.AgentRunEntity; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.security.Principal; import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; @@ -38,7 +36,6 @@ public class ModelService { private final ModelConfigMapper modelMapper; private final ModelAssignmentMapper assignmentMapper; private final AgentRunMapper runMapper; - private final UserService userService; private final KeyCipher keyCipher; private final ObjectMapper objectMapper; private final HttpClient httpClient; @@ -49,7 +46,6 @@ public class ModelService { * @param modelMapper 模型配置 Mapper * @param assignmentMapper 角色模型分配 Mapper * @param runMapper Agent Run Mapper - * @param userService 用户服务 * @param keyCipher 密钥加密器 * @param objectMapper JSON 映射器 */ @@ -58,14 +54,12 @@ public class ModelService { ModelConfigMapper modelMapper, ModelAssignmentMapper assignmentMapper, AgentRunMapper runMapper, - UserService userService, KeyCipher keyCipher, ObjectMapper objectMapper) { this( modelMapper, assignmentMapper, runMapper, - userService, keyCipher, objectMapper, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build()); @@ -77,7 +71,6 @@ public class ModelService { * @param modelMapper 模型配置 Mapper * @param assignmentMapper 角色模型分配 Mapper * @param runMapper Agent Run Mapper - * @param userService 用户服务 * @param keyCipher 密钥加密器 * @param objectMapper JSON 映射器 * @param httpClient 模型连接使用的 HTTP 客户端 @@ -86,14 +79,12 @@ public class ModelService { ModelConfigMapper modelMapper, ModelAssignmentMapper assignmentMapper, AgentRunMapper runMapper, - UserService userService, KeyCipher keyCipher, ObjectMapper objectMapper, HttpClient httpClient) { this.modelMapper = modelMapper; this.assignmentMapper = assignmentMapper; this.runMapper = runMapper; - this.userService = userService; this.keyCipher = keyCipher; this.objectMapper = objectMapper; this.httpClient = httpClient; @@ -118,12 +109,12 @@ public class ModelService { * * @param id 可选模型 ID * @param input 模型输入 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 保存后的模型 */ @Transactional - public ModelView save(UUID id, ModelInput input, Principal principal) { - UUID userId = userService.requireUserId(principal.getName()); + public ModelView save(UUID id, ModelInput input, UUID userId) { + contextWindow(input.capabilities()); if (id == null) { if (input.apiKey() == null || input.apiKey().isBlank()) { @@ -165,15 +156,15 @@ public class ModelService { * 将模型设置为所有角色默认模型。 * * @param id 模型 ID - * @param principal 当前用户 + * @param userId 当前操作者 ID */ @Transactional - public void setDefault(UUID id, Principal principal) { + public void setDefault(UUID id, UUID userId) { ModelView target = require(id); if (!target.enabled()) { throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型"); } - UUID userId = userService.requireUserId(principal.getName()); + modelMapper.clearDefault(); if (modelMapper.setDefault(id) != 1) { throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); @@ -387,7 +378,7 @@ public class ModelService { private Map parseCapabilities(String json) { try { return objectMapper.readValue(json, Map.class); - } catch (IOException exception) { + } catch (tools.jackson.core.JacksonException exception) { throw new ApiException( HttpStatus.INTERNAL_SERVER_ERROR, "MODEL_CAPABILITIES_INVALID", @@ -528,7 +519,7 @@ public class ModelService { private String json(Object value) { try { return objectMapper.writeValueAsString(value == null ? Map.of() : value); - } catch (IOException exception) { + } catch (tools.jackson.core.JacksonException exception) { throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化"); } } diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectEntity.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectEntity.java index 47a379a..f584301 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table; import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.project} 表的企业申报项目实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileEntity.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileEntity.java index 39c5f38..d747d04 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table; import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.project_file} 表的项目材料实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileMapper.java similarity index 56% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileMapper.java index 1b6ceec..00b03dd 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileMapper.java @@ -1,10 +1,11 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.core.BaseMapper; -import tech.easyflow.manuagent.entity.ProjectFileEntity; +import tech.easyflow.manuagent.agent.project.ProjectFileEntity; /** * 提供 {@code app.project_file} 表的单表持久化能力。 */ +@org.apache.ibatis.annotations.Mapper public interface ProjectFileMapper extends BaseMapper { } diff --git a/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileService.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileService.java index c4bb2a5..58f79e4 100644 --- a/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectFileService.java @@ -1,11 +1,10 @@ -package tech.easyflow.manuagent.project; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.core.query.QueryWrapper; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.ProjectFileEntity; -import tech.easyflow.manuagent.mapper.ProjectFileMapper; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.project.ProjectFileEntity; +import tech.easyflow.manuagent.agent.project.ProjectFileMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -15,7 +14,6 @@ import java.nio.file.Path; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.security.Principal; import java.time.OffsetDateTime; import java.util.Comparator; import java.util.HexFormat; @@ -42,7 +40,6 @@ public class ProjectFileService { "png", "jpg", "jpeg", "webp", "vsdx", "dwg"); private final ProjectFileMapper fileMapper; - private final UserService userService; private final ProjectService projectService; private final Path dataRoot; private final Tika tika = new Tika(); @@ -51,17 +48,14 @@ public class ProjectFileService { * 创建材料服务。 * * @param fileMapper 项目材料 Mapper - * @param userService 用户服务 * @param projectService 项目服务 * @param properties 应用配置 */ public ProjectFileService( ProjectFileMapper fileMapper, - UserService userService, ProjectService projectService, - AppProperties properties) { + AgentProperties properties) { this.fileMapper = fileMapper; - this.userService = userService; this.projectService = projectService; this.dataRoot = properties.dataRoot().toAbsolutePath().normalize(); } @@ -110,11 +104,11 @@ public class ProjectFileService { * @param projectId 项目 ID * @param file 上传文件 * @param relativePath 浏览器提供的文件夹内相对路径;单文件上传时可为空 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 文件元数据 */ @Transactional - public FileView upload(UUID projectId, MultipartFile file, String relativePath, Principal principal) { + public FileView upload(UUID projectId, MultipartFile file, String relativePath, UUID userId) { projectService.require(projectId); String originalName = safeName(file.getOriginalFilename()); String extension = extension(originalName); @@ -143,7 +137,7 @@ public class ProjectFileService { } Files.move(temporary, target); moved = true; - UUID userId = userService.requireUserId(principal.getName()); + // 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。 ProjectFileEntity entity = new ProjectFileEntity(); entity.setId(fileId); diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectMapper.java similarity index 87% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectMapper.java index b60c77e..a9fc38e 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectMapper.java @@ -1,13 +1,14 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.core.BaseMapper; import java.util.UUID; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.ProjectEntity; +import tech.easyflow.manuagent.agent.project.ProjectEntity; /** * 提供项目基础 CRUD、阶段更新和项目级联清理所需的显式 SQL 接口。 */ +@org.apache.ibatis.annotations.Mapper public interface ProjectMapper extends BaseMapper { /** 判断项目是否仍有运行中的 Agent。 */ diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanEntity.java similarity index 93% rename from server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanEntity.java index f9b8a16..1cbf586 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; import org.apache.ibatis.type.JdbcType; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.project_plan} 表的不可变规划版本实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanMapper.java similarity index 85% rename from server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanMapper.java index 92513ec..a25253f 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectPlanMapper.java @@ -1,13 +1,14 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.core.BaseMapper; import java.util.UUID; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.ProjectPlanEntity; +import tech.easyflow.manuagent.agent.project.ProjectPlanEntity; /** * 提供规划版本查询以及带条件的草稿写入、确认能力。 */ +@org.apache.ibatis.annotations.Mapper public interface ProjectPlanMapper extends BaseMapper { /** 插入项目下一版草稿并返回完整记录。 */ diff --git a/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectService.java similarity index 90% rename from server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectService.java index bece554..333054b 100644 --- a/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/project/ProjectService.java @@ -1,16 +1,14 @@ -package tech.easyflow.manuagent.project; +package tech.easyflow.manuagent.agent.project; import com.mybatisflex.core.query.QueryWrapper; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ProjectEntity; -import tech.easyflow.manuagent.entity.ProjectPlanEntity; -import tech.easyflow.manuagent.mapper.ProjectMapper; -import tech.easyflow.manuagent.mapper.ProjectPlanMapper; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.security.Principal; +import tech.easyflow.manuagent.agent.project.ProjectEntity; +import tech.easyflow.manuagent.agent.project.ProjectPlanEntity; +import tech.easyflow.manuagent.agent.project.ProjectMapper; +import tech.easyflow.manuagent.agent.project.ProjectPlanMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -26,7 +24,6 @@ public class ProjectService { private final ProjectMapper projectMapper; private final ProjectPlanMapper planMapper; - private final UserService userService; private final ObjectMapper objectMapper; /** @@ -34,17 +31,14 @@ public class ProjectService { * * @param projectMapper 项目 Mapper * @param planMapper 规划版本 Mapper - * @param userService 用户服务 * @param objectMapper JSON 映射器 */ public ProjectService( ProjectMapper projectMapper, ProjectPlanMapper planMapper, - UserService userService, ObjectMapper objectMapper) { this.projectMapper = projectMapper; this.planMapper = planMapper; - this.userService = userService; this.objectMapper = objectMapper; } @@ -53,14 +47,14 @@ public class ProjectService { * * @param companyName 企业名称 * @param applicationLevel 申报等级 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 新项目 */ @Transactional - public ProjectView create(String companyName, String applicationLevel, Principal principal) { + public ProjectView create(String companyName, String applicationLevel, UUID userId) { String level = normalizeLevel(applicationLevel); UUID id = UUID.randomUUID(); - UUID userId = userService.requireUserId(principal.getName()); + String threadId = "project-" + id; ProjectEntity entity = new ProjectEntity(); entity.setId(id); @@ -176,12 +170,12 @@ public class ProjectService { * @param projectId 项目 ID * @param planId 草稿规划 ID * @param plan 用户确认后的完整规划 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 已确认规划 */ @Transactional - public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) { - UUID userId = userService.requireUserId(principal.getName()); + public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, UUID userId) { + ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId); if (confirmed == null) { throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在"); @@ -201,7 +195,7 @@ public class ProjectService { objectMapper.readTree(entity.getPlanJson()), entity.getConfirmedAt(), entity.getCreatedAt()); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { // 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。 throw new IllegalStateException("规划 JSON 无法解析", exception); } diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventEntity.java similarity index 92% rename from server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventEntity.java index 7ed6e0a..81e4914 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.runtime; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table; import java.time.OffsetDateTime; import java.util.UUID; import org.apache.ibatis.type.JdbcType; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.agent_event} 表的持久化 Agent 事件。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventMapper.java similarity index 85% rename from server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventMapper.java index 82f255c..c0fe15d 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventMapper.java @@ -1,13 +1,14 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.runtime; import com.mybatisflex.core.BaseMapper; import java.util.UUID; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.AgentEventEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventEntity; /** * 提供 Agent 事件写入、游标回放及运行恢复所需的持久化能力。 */ +@org.apache.ibatis.annotations.Mapper public interface AgentEventMapper extends BaseMapper { /** diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventService.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventService.java index b6f0fdb..0eda850 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentEventService.java @@ -1,8 +1,8 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; import com.mybatisflex.core.query.QueryWrapper; import java.time.OffsetDateTime; import java.util.List; @@ -17,8 +17,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.core.scheduler.Schedulers; -import tech.easyflow.manuagent.entity.AgentEventEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentEventEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; /** * 持久化并查询项目级 AG-UI 事件。 @@ -175,7 +175,7 @@ public class AgentEventService { entity.getEventType(), objectMapper.readTree(entity.getPayloadJson()), entity.getCreatedAt()); - } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + } catch (tools.jackson.core.JacksonException exception) { throw new IllegalStateException("Agent 事件 JSON 无法解析", exception); } } diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionService.java similarity index 78% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionService.java index c5c38ff..483ba14 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionService.java @@ -1,18 +1,22 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import tech.easyflow.manuagent.project.ProjectFileService; -import tech.easyflow.manuagent.project.ProjectService; -import tech.easyflow.manuagent.skill.SkillService; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tech.easyflow.manuagent.agent.skill.SkillService; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.model.ModelHttpException; import io.agentscope.core.model.transport.HttpTransportException; import java.time.Duration; +import java.time.Instant; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.common.ApiException; +import org.springframework.http.HttpStatus; import java.util.List; import java.util.Map; import java.util.UUID; @@ -36,6 +40,7 @@ public class AgentExecutionService { private final AgentEventService eventService; private final ProjectFileService fileService; private final SkillService skillService; + private final Duration runTimeout; /** * 创建 Agent 执行服务。 @@ -45,18 +50,24 @@ public class AgentExecutionService { * @param eventService 事件服务 * @param fileService 工作区服务 * @param skillService Skill 服务 + * @param properties Run 总时限配置 */ public AgentExecutionService( ObjectMapper objectMapper, AgentFactory agentFactory, AgentEventService eventService, ProjectFileService fileService, - SkillService skillService) { + SkillService skillService, + AgentProperties properties) { this.objectMapper = objectMapper; this.agentFactory = agentFactory; this.eventService = eventService; this.fileService = fileService; this.skillService = skillService; + this.runTimeout = properties.runTimeout(); + if (runTimeout == null || runTimeout.isZero() || runTimeout.isNegative()) { + throw new IllegalArgumentException("app.run-timeout 必须大于 0"); + } } /** @@ -79,6 +90,7 @@ public class AgentExecutionService { EventAccumulator accumulator = new EventAccumulator(project.id(), run.id()); for (int reconnects = 0; ; reconnects++) { ensureRunning.run(); + remainingTime(run); String attemptPrompt = reconnects == 0 ? prompt : """ 模型连接刚刚中断。请恢复同一线程的会话状态,读取 MEMORY.md 和工作区已有成果, 检查未完成的输出后从中断处继续;复用已经完成的工具结果,不要重复已完成操作。 @@ -90,13 +102,15 @@ public class AgentExecutionService { .build(); try (AgentFactory.AgentHandle handle = agentFactory.create( project.id(), run.modelConfigId(), skillService.enabledNames())) { + Mono deadline = Mono.delay(remainingTime(run)).then(Mono.error(timeoutFailure())); handle.adapter().run(input) - .takeUntilOther(stopSignal) + .takeUntilOther(Mono.firstWithSignal(stopSignal, deadline)) .bufferTimeout(64, Duration.ofMillis(120)) - .doOnNext(accumulator::accept) + .doOnNext(batch -> accumulator.accept(batch, handle.runtime())) .blockLast(); accumulator.flush(); ensureRunning.run(); + remainingTime(run); return; } catch (RuntimeException exception) { accumulator.flush(); @@ -109,7 +123,7 @@ public class AgentExecutionService { int attempt = reconnects + 1; eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of( "attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS)); - pauseBeforeReconnect(attempt, interrupted); + pauseBeforeReconnect(attempt, interrupted, remainingTime(run)); } } } @@ -137,9 +151,19 @@ public class AgentExecutionService { return false; } - private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted) { + Duration remainingTime(AgentRunService.RunView run) { + Duration remaining = Duration.between(Instant.now(), run.startedAt().toInstant().plus(runTimeout)); + if (remaining.isNegative() || remaining.isZero()) throw timeoutFailure(); + return remaining; + } + + private ApiException timeoutFailure() { + return new ApiException(HttpStatus.REQUEST_TIMEOUT, "AGENT_RUN_TIMEOUT", "任务已超过运行时限,现有成果已保留,可稍后继续"); + } + + private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted, Duration remaining) { try { - Thread.sleep(Math.min(8_000L, 500L << (attempt - 1))); + Thread.sleep(Math.min(remaining.toMillis(), Math.min(8_000L, 500L << (attempt - 1)))); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); if (interrupted.getAsBoolean()) { @@ -173,18 +197,24 @@ public class AgentExecutionService { this.runId = runId; } - private void accept(List batch) { + private void accept(List batch, AgentRuntimeMiddleware runtime) { for (AguiEvent event : batch) { - accept(event); + accept(event, runtime); } if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) { flush(); } } - private void accept(AguiEvent event) { + private void accept(AguiEvent event, AgentRuntimeMiddleware runtime) { String type = event.getType().name(); - if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED") || type.equals("RUN_ERROR")) { + // Adapter 将基础设施异常转成事件;必须终止执行,不能当作缺少 JSON 再次调用 Agent。 + if (event instanceof AguiEvent.RunError error) { + String message = sanitize(objectMapper.getNodeFactory().textNode(error.message()), + fileService.projectRoot(projectId).toString()).asText(); + throw new IllegalStateException("Agent 运行错误:" + message, runtime == null ? null : runtime.modelFailure()); + } + if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED")) { return; } ObjectNode payload = (ObjectNode) sanitize( diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentFactory.java similarity index 89% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentFactory.java index d1652ca..1add35c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentFactory.java @@ -1,9 +1,9 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.model.ModelService; -import tech.easyflow.manuagent.project.ProjectFileService; -import com.fasterxml.jackson.databind.ObjectMapper; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.model.ModelService; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tools.jackson.databind.ObjectMapper; import io.agentscope.core.agui.adapter.AguiAdapterConfig; import io.agentscope.core.agui.adapter.AguiAgentAdapter; import io.agentscope.core.skill.AgentSkill; @@ -11,6 +11,8 @@ import io.agentscope.core.skill.repository.AgentSkillRepository; import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.state.JsonFileAgentStateStore; +import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; import io.agentscope.extensions.model.openai.OpenAIChatModel; import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.HarnessAgent; @@ -45,7 +47,7 @@ public class AgentFactory { private final ModelService modelService; private final PostgresSkillRepository skillRepository; private final ProjectFileService fileService; - private final AppProperties properties; + private final AgentProperties properties; private final ObjectMapper objectMapper; private final String systemPrompt; private final String compactionPrompt; @@ -63,7 +65,7 @@ public class AgentFactory { ModelService modelService, PostgresSkillRepository skillRepository, ProjectFileService fileService, - AppProperties properties, + AgentProperties properties, ObjectMapper objectMapper) { this.modelService = modelService; this.skillRepository = skillRepository; @@ -72,6 +74,7 @@ public class AgentFactory { this.objectMapper = objectMapper; this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词"); this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词"); + AgentStateFiles.migrateExisting(properties.dataRoot()); } /** @@ -96,7 +99,7 @@ public class AgentFactory { Map environment = new HashMap<>(); environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile())); environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin"); - environment.put("NODE_PATH", "/opt/agent-runtime/node_modules"); + environment.put("NODE_PATH", "/opt/sandbox/node_modules"); environment.put("SKILL_SESSION_ID", "project-" + projectId); WorkspaceSpec workspace = new WorkspaceSpec(); @@ -115,6 +118,7 @@ public class AgentFactory { throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception); } DockerFilesystemSpec filesystem = new DockerFilesystemSpec() + .client(new RecoverableDockerClient(properties.sandboxImage())) .image(properties.sandboxImage()) .workspaceRoot("/workspace") .environment(environment) @@ -133,13 +137,17 @@ public class AgentFactory { CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt); Toolkit toolkit = new Toolkit(); - DocumentViewTool documentView = new DocumentViewTool(objectMapper); + DocumentViewTool documentView = new DocumentViewTool(objectMapper, fileService, projectId); toolkit.registerTool(documentView); + AgentRuntimeMiddleware runtime = new AgentRuntimeMiddleware(); + Path stateRoot = AgentStateFiles.prepare(properties.dataRoot(), AgentStateFiles.legacyRoot(), projectId); HarnessAgent agent = HarnessAgent.builder() .name("smart-factory-agent") .description("智能工厂申报书规划、编写与评审 Agent") .sysPrompt(systemPrompt) .model(chatModel) + .stateStore(new JsonFileAgentStateStore(stateRoot)) + .middleware(runtime) .toolkit(toolkit) .workspace(projectRoot) .filesystem(filesystem) @@ -148,6 +156,8 @@ public class AgentFactory { .toolResultEviction(ToolResultEvictionConfig.defaults()) .maxContextTokens(model.contextWindow()) .maxIters(96) + // 中断的工具调用以失败结果补齐,由模型检查已有成果后继续;不绕过权限确认。 + .enablePendingToolRecovery(true) .enableAgentTracingLog(false) .disableSubagents() .build(); @@ -155,15 +165,16 @@ public class AgentFactory { agent.getWorkspaceManager().getFilesystem(), WorkspacePathNormalizer.of("/workspace"), objectMapper)); + agent.getToolkit().registerTool(new SandboxTools( + (AbstractSandboxFilesystem) agent.getWorkspaceManager().getFilesystem(), objectMapper)); documentView.bind(agent); AguiAdapterConfig config = AguiAdapterConfig.builder() .enableReasoning(true) .emitToolCallArgs(true) .emitStateEvents(false) - .runTimeout(properties.runTimeout()) .defaultAgentId("smart-factory-agent") .build(); - return new AgentHandle(new AguiAgentAdapter(agent, config), agent); + return new AgentHandle(new AguiAgentAdapter(agent, config), agent, runtime); } /** @@ -240,8 +251,9 @@ public class AgentFactory { * * @param adapter AG-UI 适配器 * @param agent Harness Agent + * @param runtime 模型调用约束及原始异常 */ - public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent) implements AutoCloseable { + public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent, AgentRuntimeMiddleware runtime) implements AutoCloseable { /** * 结束 Agent 并释放 Docker 沙箱资源。 diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentOutputService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentOutputService.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentOutputService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentOutputService.java index 7e2d43a..eaddd77 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentOutputService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentOutputService.java @@ -1,10 +1,10 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.project.ProjectFileService; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunEntity.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunEntity.java index 8f3b347..efb0f8f 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.runtime; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators; import java.time.OffsetDateTime; import java.util.UUID; import org.apache.ibatis.type.JdbcType; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射 {@code app.agent_run} 表的 Agent 运行状态实体。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunMapper.java similarity index 87% rename from server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunMapper.java index 777c253..d45bca4 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunMapper.java @@ -1,13 +1,14 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.runtime; import com.mybatisflex.core.BaseMapper; import java.util.UUID; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.AgentRunEntity; +import tech.easyflow.manuagent.agent.runtime.AgentRunEntity; /** * 提供 Agent Run 查询以及带前置状态条件的原子状态迁移。 */ +@org.apache.ibatis.annotations.Mapper public interface AgentRunMapper extends BaseMapper { /** 将等待输入的 Run 标记为已完成。 */ diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunService.java similarity index 84% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunService.java index 3eaaa96..3e330a9 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunService.java @@ -1,22 +1,24 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import tech.easyflow.manuagent.artifact.ArtifactService; -import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.agent.artifact.ArtifactService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.project.ProjectFileService; -import tech.easyflow.manuagent.project.ProjectService; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.security.Principal; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.beans.factory.annotation.Value; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -26,7 +28,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import reactor.core.publisher.Sinks; -import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; /** * 驱动材料检验、规划 Ask 和自动编写 Run。 @@ -43,10 +45,11 @@ public class AgentRunService { private final AgentEventService eventService; private final ProjectService projectService; private final ProjectFileService fileService; - private final UserService userService; private final ArtifactService artifactService; private final ExecutorService executor; private final TransactionTemplate transactions; + static final int MAX_OUTPUT_REPAIRS = 3; + private final Semaphore runSlots; private final ConcurrentMap activeRuns = new ConcurrentHashMap<>(); /** @@ -60,10 +63,10 @@ public class AgentRunService { * @param eventService 事件服务 * @param projectService 项目服务 * @param fileService 材料与工作区服务 - * @param userService 用户服务 * @param artifactService 产物服务 * @param applicationExecutor 虚拟线程执行器 * @param transactions 编程式事务模板 + * @param maxConcurrentRuns 单进程同时运行的任务上限 */ public AgentRunService( AgentRunMapper runMapper, @@ -74,10 +77,10 @@ public class AgentRunService { AgentEventService eventService, ProjectService projectService, ProjectFileService fileService, - UserService userService, ArtifactService artifactService, ExecutorService applicationExecutor, - TransactionTemplate transactions) { + TransactionTemplate transactions, + @Value("${app.max-concurrent-runs:2}") int maxConcurrentRuns) { this.runMapper = runMapper; this.objectMapper = objectMapper; this.executionService = executionService; @@ -86,26 +89,27 @@ public class AgentRunService { this.eventService = eventService; this.projectService = projectService; this.fileService = fileService; - this.userService = userService; this.artifactService = artifactService; this.executor = applicationExecutor; this.transactions = transactions; + if (maxConcurrentRuns < 1) throw new IllegalArgumentException("app.max-concurrent-runs 必须大于 0"); + this.runSlots = new Semaphore(maxConcurrentRuns); } /** * 后台启动材料检验。 * * @param projectId 项目 ID - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 新 Run */ @Transactional - public RunView startMaterialCheck(UUID projectId, Principal principal) { + public RunView startMaterialCheck(UUID projectId, UUID userId) { ProjectService.ProjectView project = projectService.require(projectId); - userService.requireUserId(principal.getName()); + RunView previous = latest(projectId); RunView run = runStore.create(projectId, "INITIAL", null); projectService.updateStatus(projectId, "MATERIAL_CHECK"); - afterCommit(run.id(), () -> executeMaterialRun(project, run, false)); + afterCommit(run.id(), () -> executeMaterialRun(project, run, false), previous == null ? null : previous.id()); return run; } @@ -114,13 +118,13 @@ public class AgentRunService { * * @param projectId 项目 ID * @param response 用户对材料缺口的处理结果 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 新规划 Run */ @Transactional - public RunView confirmMaterials(UUID projectId, JsonNode response, Principal principal) { + public RunView confirmMaterials(UUID projectId, JsonNode response, UUID userId) { ProjectService.ProjectView project = projectService.require(projectId); - UUID userId = userService.requireUserId(principal.getName()); + RunView waiting = runStore.requireWaiting(projectId, "material_check"); if (!response.isObject() || !response.path("decisions").isArray()) { throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口"); @@ -129,7 +133,7 @@ public class AgentRunService { runStore.completeWaiting(waiting.id()); RunView run = runStore.create(projectId, "RESUME", waiting.id()); projectService.updateStatus(projectId, "PLANNING"); - afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false)); + afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false), waiting.id()); return run; } @@ -139,7 +143,7 @@ public class AgentRunService { * @param projectId 项目 ID * @param planId 规划 ID * @param confirmedPlan 用户确认后的规划 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 已确认规划及编写 Run */ @Transactional @@ -147,7 +151,7 @@ public class AgentRunService { UUID projectId, UUID planId, JsonNode confirmedPlan, - Principal principal) { + UUID userId) { ProjectService.ProjectView project = projectService.require(projectId); ProjectService.PlanView current = projectService.currentPlan(projectId); if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) { @@ -158,11 +162,11 @@ public class AgentRunService { } outputService.validateConfirmedPlan(confirmedPlan); RunView waiting = runStore.requireWaiting(projectId, "planning"); - ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, principal); + ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, userId); eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId)); runStore.completeWaiting(waiting.id()); RunView run = runStore.create(projectId, "RESUME", waiting.id()); - afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false)); + afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), waiting.id()); return new ConfirmPlanResult(plan, run); } @@ -188,7 +192,7 @@ public class AgentRunService { } RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id()); projectService.updateStatus(projectId, "WRITING"); - afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false)); + afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), latest == null ? null : latest.id()); return run; } @@ -196,13 +200,13 @@ public class AgentRunService { * 立即停止当前 Run,并保留项目阶段和工作区成果。 * * @param projectId 项目 ID - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 已中断的 Run */ @Transactional - public RunView stop(UUID projectId, Principal principal) { + public RunView stop(UUID projectId, UUID userId) { projectService.require(projectId); - userService.requireUserId(principal.getName()); + RunView run = latest(projectId); if (run == null || !"RUNNING".equals(run.status())) { throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); @@ -223,12 +227,12 @@ public class AgentRunService { * 从已中断 Run 的原阶段继续,复用同一线程状态和工作区成果。 * * @param projectId 项目 ID - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 新的恢复 Run */ @Transactional - public RunView resume(UUID projectId, Principal principal) { - return resume(projectId, null, principal); + public RunView resume(UUID projectId, UUID userId) { + return resume(projectId, null, userId); } /** @@ -236,13 +240,13 @@ public class AgentRunService { * * @param projectId 项目 ID * @param modelConfigId 替代模型;为空时使用当前默认模型 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 新的恢复 Run */ @Transactional - public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) { + public RunView resume(UUID projectId, UUID modelConfigId, UUID userId) { ProjectService.ProjectView project = projectService.require(projectId); - UUID userId = userService.requireUserId(principal.getName()); + RunView interrupted = latest(projectId); if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) { throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务"); @@ -250,7 +254,7 @@ public class AgentRunService { String phase = runStore.interruptedPhase(interrupted, project); RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId); projectService.updateStatus(projectId, phase); - scheduleResume(project, run, userId, phase); + scheduleResume(project, run, userId, phase, interrupted.id()); return run; } @@ -263,13 +267,13 @@ public class AgentRunService { * * @param projectId 项目 ID * @param modelConfigId 替代模型 ID - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 绑定替代模型的新恢复 Run */ @Transactional - public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) { + public RunView switchModel(UUID projectId, UUID modelConfigId, UUID userId) { ProjectService.ProjectView project = projectService.require(projectId); - UUID userId = userService.requireUserId(principal.getName()); + RunView current = latest(projectId); if (current == null || !"RUNNING".equals(current.status())) { throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); @@ -291,7 +295,7 @@ public class AgentRunService { control.cancel(); } }); - scheduleResume(project, replacement, userId, phase); + scheduleResume(project, replacement, userId, phase, current.id()); return replacement; } @@ -302,21 +306,22 @@ public class AgentRunService { ProjectService.ProjectView project, RunView run, UUID userId, - String phase) { + String phase, + UUID previousRunId) { switch (phase) { case "MATERIAL_CHECK" -> afterCommit( - run.id(), () -> executeMaterialRun(project, run, true)); + run.id(), () -> executeMaterialRun(project, run, true), previousRunId); case "PLANNING" -> { JsonNode materialResponse = runStore.latestMaterialResponse(project.id()); afterCommit(run.id(), () -> executePlanningRun( - project, run, userId, materialResponse, true)); + project, run, userId, materialResponse, true), previousRunId); } case "WRITING" -> { ProjectService.PlanView plan = projectService.currentPlan(project.id()); if (plan == null || !"CONFIRMED".equals(plan.status())) { throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认"); } - afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true)); + afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true), previousRunId); } default -> throw new ApiException( HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段"); @@ -383,8 +388,12 @@ public class AgentRunService { while (true) { try { return outputService.readMaterialCheck(project.id()); - } catch (ApiException | IOException exception) { + } catch (ApiException | IOException | tools.jackson.core.JacksonException exception) { runStore.ensureRunning(run.id()); + if (repairRound >= MAX_OUTPUT_REPAIRS) { + throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID", + "结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续"); + } repairRound++; log.warn("Agent 未生成有效材料检验结果,继续同一线程修复,runId={},round={}", run.id(), repairRound, exception); @@ -457,8 +466,12 @@ public class AgentRunService { while (true) { try { return outputService.readProposedPlan(project.id()); - } catch (ApiException | IOException exception) { + } catch (ApiException | IOException | tools.jackson.core.JacksonException exception) { runStore.ensureRunning(run.id()); + if (repairRound >= MAX_OUTPUT_REPAIRS) { + throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID", + "结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续"); + } repairRound++; log.warn("Agent 未生成有效建设规划,继续同一线程修复,runId={},round={}", run.id(), repairRound, exception); @@ -543,29 +556,57 @@ public class AgentRunService { * @param task 后台任务 */ private void afterCommit(UUID runId, Runnable task) { - RunControl control = new RunControl(); - activeRuns.put(runId, control); + afterCommit(runId, task, null); + } + + private void afterCommit(UUID runId, Runnable task, UUID previousRunId) { + // ponytail: 单进程并发上限;部署多个后端副本时改用数据库租约统一占位。 + RunControl control; + synchronized (activeRuns) { + RunControl previous = previousRunId == null ? null : activeRuns.get(previousRunId); + if (previous == null && !runSlots.tryAcquire()) { + throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, "AGENT_CAPACITY_REACHED", "当前运行任务已达上限,请稍后重试"); + } + control = new RunControl(previous); + activeRuns.put(runId, control); + } TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - executor.submit(() -> { - try { - task.run(); - } finally { - activeRuns.remove(runId, control); - } - }); + try { + executor.submit(() -> { + try { + // 同一项目接续复用名额,但须等旧 Agent 关闭并保存快照后再读写工作区。 + control.previousFinished.join(); + task.run(); + } finally { + release(runId, control); + } + }); + } catch (RuntimeException exception) { + release(runId, control); + fail(runStore.require(runId), exception); + } } @Override public void afterCompletion(int status) { if (status != TransactionSynchronization.STATUS_COMMITTED) { - activeRuns.remove(runId, control); + release(runId, control); } } }); } + private void release(UUID runId, RunControl control) { + synchronized (activeRuns) { + if (!activeRuns.remove(runId, control)) return; + if (control.slotUsers.decrementAndGet() == 0) runSlots.release(); + } + // 回滚或提交任务失败也不能让后继任务越过仍在释放资源的祖先任务。 + control.previousFinished.thenRun(() -> control.finished.complete(null)); + } + /** * 在当前事务成功提交后执行短操作。 * @@ -647,7 +688,7 @@ public class AgentRunService { int updated = runMapper.failRunning(run.id(), message); if (updated == 1) { eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of( - "code", "AGENT_RUN_FAILED", "message", message)); + "code", exception instanceof ApiException api ? api.code() : "AGENT_RUN_FAILED", "message", message)); projectService.updateStatus(run.projectId(), "FAILED"); } }); @@ -708,6 +749,15 @@ public class AgentRunService { private static final class RunControl { private final Sinks.One stopSignal = Sinks.one(); + private final AtomicInteger slotUsers; + private final CompletableFuture previousFinished; + private final CompletableFuture finished = new CompletableFuture<>(); + + private RunControl(RunControl previous) { + slotUsers = previous == null ? new AtomicInteger(1) : previous.slotUsers; + if (previous != null) slotUsers.incrementAndGet(); + previousFinished = previous == null ? CompletableFuture.completedFuture(null) : previous.finished; + } /** * 取消 Agent 流订阅,停止继续输出和后续工具调用。 diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunStore.java similarity index 94% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunStore.java index 83252c5..88fd47e 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRunStore.java @@ -1,19 +1,19 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.project.ProjectService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import tech.easyflow.manuagent.entity.AgentRunEntity; -import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunEntity; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; /** * 集中读写 Agent Run 持久化状态。 @@ -172,7 +172,7 @@ public class AgentRunStore { throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致"); } return run; - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取"); } } @@ -228,7 +228,7 @@ public class AgentRunStore { } try { return objectMapper.readTree(value); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw new ApiException( HttpStatus.INTERNAL_SERVER_ERROR, "MATERIAL_RESPONSE_INVALID", diff --git a/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddleware.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddleware.java new file mode 100644 index 0000000..fc06529 --- /dev/null +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddleware.java @@ -0,0 +1,44 @@ +package tech.easyflow.manuagent.agent.runtime; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ModelCallInput; +import java.util.ArrayList; +import java.util.Map; +import java.util.function.Function; +import reactor.core.publisher.Flux; + +/** 在每次模型调用前刷新正文语言约束,并保留被 AG-UI 转换丢失的异常原因。 */ +final class AgentRuntimeMiddleware implements MiddlewareBase { + private volatile Throwable modelFailure; + + @Override + public Flux onModelCall(Agent agent, RuntimeContext context, ModelCallInput input, + Function> next) { + var messages = new ArrayList<>(input.messages()); + messages.add(Msg.builder().role(MsgRole.USER).name("system") + .content(TextBlock.builder().text(""" + + 除非用户明确要求其他语言,本次及后续面向用户的正文、进度说明和最终回复均使用简体中文。 + 英文 Skill、工具说明和历史英文消息不改变此要求;代码、命令、路径、标准原文和专有名词保留原语言。 + 只说明业务进展和必要确认事项,不输出工具选择、脚本调试或内部文件操作的过程旁白。 + + """).build()) + .metadata(Map.of(Msg.METADATA_SYNTHETIC, true, Msg.METADATA_REMINDER_KIND, "response_language")) + .build()); + return Flux.defer(() -> { + modelFailure = null; + return next.apply(new ModelCallInput(messages, input.tools(), input.options(), input.model())) + .doOnError(error -> modelFailure = error); + }); + } + + Throwable modelFailure() { + return modelFailure; + } +} diff --git a/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentStateFiles.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentStateFiles.java new file mode 100644 index 0000000..be5c22e --- /dev/null +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/AgentStateFiles.java @@ -0,0 +1,75 @@ +package tech.easyflow.manuagent.agent.runtime; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Base64; +import java.util.Comparator; +import java.util.UUID; + +/** 状态随 APP_DATA_ROOT 持久化;按项目复制旧状态,保留原件且不覆盖已迁移会话。 */ +final class AgentStateFiles { + static Path legacyRoot() { + return Path.of(System.getProperty("agentscope.state.home", + Path.of(System.getProperty("user.home"), ".agentscope", "state").toString())); + } + + static void migrateExisting(Path dataRoot) { + Path projects = dataRoot.resolve("projects"); + if (!Files.isDirectory(projects)) return; + try (var paths = Files.list(projects)) { + for (Path project : paths.filter(Files::isDirectory).toList()) { + String name = project.getFileName().toString(); + if (name.matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) { + prepare(dataRoot, legacyRoot(), UUID.fromString(name)); + } + } + } catch (IOException exception) { + throw new IllegalStateException("无法检查已有项目的 Agent 状态", exception); + } + } + + static synchronized Path prepare(Path dataRoot, Path legacyRoot, UUID projectId) { + Path root = dataRoot.toAbsolutePath().normalize().resolve("agent-state/smart-factory-agent"); + String session = "project-" + projectId; + // AgentScope 2.0.1 将 SESSION 沙箱元数据放在独立的 Base64 URL 目录中。 + // 分别补迁,兼容此前只复制了会话的项目;保留已有沙箱状态(包括删除标记)。 + String sandboxSession = Base64.getUrlEncoder().withoutPadding() + .encodeToString(("sandbox/session/" + session).getBytes(StandardCharsets.UTF_8)); + for (String slot : new String[]{sandboxSession, session}) { + Path relative = Path.of("__anon__", slot); + copyMissing(legacyRoot.resolve("smart-factory-agent").resolve(relative), root.resolve(relative)); + } + return root; + } + + private static void copyMissing(Path source, Path target) { + if (!Files.isDirectory(source) || Files.exists(target)) return; + Path staging = null; + try { + Files.createDirectories(target.getParent()); + staging = Files.createTempDirectory(target.getParent(), ".migrating-"); + try (var files = Files.walk(source)) { + for (Path file : files.toList()) { + Path destination = staging.resolve(source.relativize(file)); + if (Files.isSymbolicLink(file)) throw new IOException("旧状态目录不能包含符号链接"); + if (Files.isDirectory(file)) Files.createDirectories(destination); + else Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); + } + } + Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException exception) { + throw new IllegalStateException("无法迁移 Agent 会话状态,原状态已保留", exception); + } finally { + if (staging != null && Files.exists(staging)) { + try (var files = Files.walk(staging)) { + for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); + } catch (IOException exception) { + org.slf4j.LoggerFactory.getLogger(AgentStateFiles.class).warn("清理状态迁移临时目录失败", exception); + } + } + } + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/DocumentViewTool.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/DocumentViewTool.java similarity index 80% rename from server/src/main/java/tech/easyflow/manuagent/agent/DocumentViewTool.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/DocumentViewTool.java index dc06913..3832a5b 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/DocumentViewTool.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/DocumentViewTool.java @@ -1,8 +1,8 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; @@ -18,12 +18,15 @@ import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; import io.agentscope.harness.agent.filesystem.model.WriteResult; import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem; import java.nio.charset.StandardCharsets; +import java.io.IOException; import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.common.ApiException; /** * 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片。 @@ -33,6 +36,8 @@ public final class DocumentViewTool { private static final int TOOL_TIMEOUT_SECONDS = 180; private final ObjectMapper objectMapper; + private final ProjectFileService fileService; + private final UUID projectId; private volatile HarnessAgent harness; /** @@ -40,8 +45,10 @@ public final class DocumentViewTool { * * @param objectMapper JSON 映射器 */ - public DocumentViewTool(ObjectMapper objectMapper) { + public DocumentViewTool(ObjectMapper objectMapper, ProjectFileService fileService, UUID projectId) { this.objectMapper = objectMapper; + this.fileService = fileService; + this.projectId = projectId; } /** @@ -98,7 +105,7 @@ public final class DocumentViewTool { } ExecuteResponse execution = sandbox.execute( runtimeContext, - "python /opt/agent-runtime/document_view.py " + requestPath + " " + resultPath, + "python /opt/sandbox/document_view.py " + requestPath + " " + resultPath, TOOL_TIMEOUT_SECONDS); FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst(); if (!downloaded.isSuccess()) { @@ -114,14 +121,18 @@ public final class DocumentViewTool { List> metadata = new ArrayList<>(); for (JsonNode image : result.path("images")) { String path = image.path("path").asText(); - FileDownloadResponse imageFile = filesystem.downloadFiles(runtimeContext, List.of(path)).getFirst(); - if (!imageFile.isSuccess()) { + byte[] imageBytes; + try { + imageBytes = readPreview(path, image.path("sizeBytes").asLong(-1)); + } catch (IOException | ApiException exception) { + output.add(TextBlock.builder().text("预览图读取失败:" + + safeError(exception.getMessage(), null)).build()); continue; } output.add(ImageBlock.builder() .source(Base64Source.builder() .mediaType(image.path("mediaType").asText("image/png")) - .data(Base64.getEncoder().encodeToString(imageFile.content())) + .data(Base64.getEncoder().encodeToString(imageBytes)) .build()) .build()); metadata.add(objectMapper.convertValue(image, new TypeReference>() { })); @@ -132,6 +143,20 @@ public final class DocumentViewTool { } } + byte[] readPreview(String path, long expectedSize) throws IOException { + if (expectedSize <= 0 || expectedSize > 4 * 1024 * 1024) { + throw new IOException("预览图大小无效或超过 4 MB"); + } + // work 已绑定宿主机;复用预览路径校验,避免 SDK 的 512 KB stdout 截断 Base64。 + try (var input = fileService.preview(projectId, path).resource().getInputStream()) { + byte[] bytes = input.readNBytes((int) expectedSize + 1); + if (bytes.length != expectedSize) { + throw new IOException("预览图字节数不一致,请重新渲染"); + } + return bytes; + } + } + /** * 选择并限制返回给 Agent 的诊断信息。 * diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/PagedReadFileTool.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileTool.java similarity index 98% rename from server/src/main/java/tech/easyflow/manuagent/agent/PagedReadFileTool.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileTool.java index 9d5d286..4ad2c28 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/PagedReadFileTool.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileTool.java @@ -1,7 +1,7 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; diff --git a/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClient.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClient.java new file mode 100644 index 0000000..92150aa --- /dev/null +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClient.java @@ -0,0 +1,35 @@ +package tech.easyflow.manuagent.agent.runtime; + +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxState; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState; +import java.util.UUID; + +/** AgentScope 2.0.1 在调用结束才保存容器 ID;进程中断后使用会话的稳定名称恢复。 */ +final class RecoverableDockerClient extends DockerSandboxClient { + private final String image; + + RecoverableDockerClient(String image) { + this.image = image; + } + + @Override + public Sandbox resume(SandboxState state) { + if (state instanceof DockerSandboxState docker && docker.isContainerOwned() + && docker.getImage() != null && docker.getImage().startsWith("smart-factory-agent-runtime:")) { + // SDK 恢复时沿用持久化镜像名;更名后保留原会话和快照,使用当前沙箱镜像。 + docker.setImage(image); + } + if (state instanceof DockerSandboxState docker && docker.isContainerOwned() + && docker.getSessionId() != null + && docker.getSessionId().matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) { + String name = "agentscope-sandbox-" + UUID.fromString(docker.getSessionId()); + if (name.equals(docker.getContainerName())) { + // Docker 接受名称作为标识;不存在时仍由 SDK 从原快照重建,不丢弃会话。 + docker.setContainerId(name); + } + } + return super.resume(state); + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RunRecoveryService.java similarity index 60% rename from server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RunRecoveryService.java index 063c485..05ae219 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/RunRecoveryService.java @@ -1,18 +1,14 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; /** * 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。 */ @Component -@Order(0) -public class RunRecoveryService implements ApplicationRunner { +public class RunRecoveryService { private final AgentRunMapper runMapper; @@ -28,11 +24,9 @@ public class RunRecoveryService implements ApplicationRunner { /** * 将仍为 RUNNING 的旧 Run 标记为已中断。 * - * @param args 启动参数 */ - @Override @Transactional - public void run(ApplicationArguments args) { + public void recoverInterruptedRuns() { runMapper.interruptRunningAfterRestart(); } } diff --git a/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/SandboxTools.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/SandboxTools.java new file mode 100644 index 0000000..9a3d3d8 --- /dev/null +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/runtime/SandboxTools.java @@ -0,0 +1,111 @@ +package tech.easyflow.manuagent.agent.runtime; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; +import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import tools.jackson.databind.ObjectMapper; + +/** 修正 2.0.1 的编辑命令换行,并保留 shell 管道的真实退出状态。 */ +final class SandboxTools { + private final AbstractSandboxFilesystem filesystem; + private final ObjectMapper mapper; + + SandboxTools(AbstractSandboxFilesystem filesystem, ObjectMapper mapper) { + this.filesystem = filesystem; + this.mapper = mapper; + } + + @Tool(name = "execute", description = "执行 Bash 命令,启用 errexit 和 pipefail。返回 success、exitCode、output;失败时先诊断再继续,不得使用 || true 掩盖必要步骤的错误。") + public Map execute(RuntimeContext context, + @ToolParam(name = "command", description = "Shell 命令") String command, + @ToolParam(name = "working_directory", description = "工作区内相对目录", required = false) String directory, + @ToolParam(name = "timeout", description = "超时秒数,默认 30", required = false) Integer timeout) { + if (command == null || command.isBlank()) return failure("命令不能为空"); + if (directory != null && !directory.isBlank()) { + if (!relativePath(directory)) return failure("working_directory 必须是工作区内相对路径"); + command = "cd " + quote(directory) + "\n" + command; + } + ExecuteResponse result = filesystem.execute(context, + "bash -e -o pipefail -c " + quote(command), timeout != null && timeout > 0 ? timeout : 30); + Map output = new LinkedHashMap<>(); + output.put("success", result.isSuccess()); + output.put("exitCode", result.exitCode()); + output.put("output", result.output() == null ? "" : result.output()); + output.put("truncated", result.truncated()); + return output; + } + + @Tool(name = "edit_file", description = "在 UTF-8 文件内精确替换。先读取文件;old_string 必须非空且唯一,除非 replace_all=true。修改已有文件应使用此工具,write_file 仅创建新文件。") + public Map editFile(RuntimeContext context, + @ToolParam(name = "path", description = "工作区内文件路径") String path, + @ToolParam(name = "old_string", description = "要替换的原文") String oldString, + @ToolParam(name = "new_string", description = "替换后的文本") String newString, + @ToolParam(name = "replace_all", description = "替换全部匹配,默认 false", required = false) Boolean replaceAll) { + path = WorkspacePathNormalizer.of("/workspace").normalize(path); + if (!relativePath(path)) return failure("path 必须是工作区内文件路径"); + if (oldString == null || oldString.isEmpty() || newString == null) return failure("old_string 不能为空,new_string 不能为 null"); + String payload = Base64.getEncoder().encodeToString(mapper.writeValueAsString(Map.of( + "path", path, "old", oldString, "new", newString, "all", Boolean.TRUE.equals(replaceAll))) + .getBytes(StandardCharsets.UTF_8)); + // 数据单独编码,避免文本中的引号、换行或 shell 字符改变脚本。 + String command = """ + python3 - <<'PY' + import base64, fcntl, json, os + p = json.loads(base64.b64decode('%s')) + try: + root = os.path.realpath('.') + path = os.path.realpath(p['path']) + if os.path.commonpath([root, path]) != root: + raise ValueError('文件必须位于工作区内') + with open(path, 'r+', encoding='utf-8', newline='') as f: + fcntl.flock(f, fcntl.LOCK_EX) + text = f.read() + count = text.count(p['old']) + if count == 0: + raise ValueError('未找到原文,请重新读取文件后编辑') + if count > 1 and not p['all']: + raise ValueError('原文出现多次,请扩大匹配范围或使用 replace_all') + updated = text.replace(p['old'], p['new'], -1 if p['all'] else 1) + f.seek(0) + f.write(updated) + f.truncate() + print(json.dumps({'success': True, 'path': p['path'], 'replacements': count if p['all'] else 1}, ensure_ascii=False)) + except (OSError, UnicodeError, ValueError) as e: + print(json.dumps({'success': False, 'error': str(e)}, ensure_ascii=False)) + PY + """.formatted(payload); + ExecuteResponse result = filesystem.execute(context, command, 30); + if (!result.isSuccess() || result.truncated()) return failure("编辑命令失败:" + result.output()); + try { + return mapper.readValue(result.output(), new tools.jackson.core.type.TypeReference>() { }); + } catch (RuntimeException exception) { + throw new IllegalStateException("无法解析编辑工具结果", exception); + } + } + + private static boolean relativePath(String path) { + if (path == null || path.isBlank() || path.startsWith("~")) return false; + try { + Path value = Path.of(path); + return !value.isAbsolute() && !value.normalize().startsWith(".."); + } catch (java.nio.file.InvalidPathException exception) { + return false; + } + } + + private static Map failure(String message) { + return Map.of("success", false, "error", message); + } + + static String quote(String value) { + return "'" + value.replace("'", "'\\''") + "'"; + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigEntity.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigEntity.java index 369c177..f4b10c5 100644 --- a/server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigEntity.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.entity; +package tech.easyflow.manuagent.agent.skill; import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Id; @@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType; import com.mybatisflex.annotation.Table; import java.time.OffsetDateTime; import java.util.UUID; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; /** * 映射应用自管的 {@code app.skill_config} 表。 diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigMapper.java similarity index 84% rename from server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigMapper.java index ce1edd7..b3a93b5 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillConfigMapper.java @@ -1,13 +1,14 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.skill; import com.mybatisflex.core.BaseMapper; import java.util.List; import org.apache.ibatis.annotations.Param; -import tech.easyflow.manuagent.entity.SkillConfigEntity; +import tech.easyflow.manuagent.agent.skill.SkillConfigEntity; /** * 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查。 */ +@org.apache.ibatis.annotations.Mapper public interface SkillConfigMapper extends BaseMapper { /** 列出全部 Skill 联合视图。 */ diff --git a/server/src/main/java/tech/easyflow/manuagent/skill/SkillPackageReader.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillPackageReader.java similarity index 99% rename from server/src/main/java/tech/easyflow/manuagent/skill/SkillPackageReader.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillPackageReader.java index 9935a29..33a7415 100644 --- a/server/src/main/java/tech/easyflow/manuagent/skill/SkillPackageReader.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillPackageReader.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.skill; +package tech.easyflow.manuagent.agent.skill; import tech.easyflow.manuagent.common.ApiException; import io.agentscope.core.skill.AgentSkill; diff --git a/server/src/main/java/tech/easyflow/manuagent/config/SkillRepositoryConfig.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillRepositoryConfig.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/config/SkillRepositoryConfig.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillRepositoryConfig.java index a5080a7..0cc7424 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/SkillRepositoryConfig.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillRepositoryConfig.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.agent.skill; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import javax.sql.DataSource; diff --git a/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillService.java similarity index 94% rename from server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillService.java index 2c9c450..f0a572a 100644 --- a/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillService.java @@ -1,19 +1,17 @@ -package tech.easyflow.manuagent.skill; +package tech.easyflow.manuagent.agent.skill; import com.mybatisflex.core.query.QueryWrapper; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.SkillConfigEntity; -import tech.easyflow.manuagent.mapper.SkillConfigMapper; -import tech.easyflow.manuagent.mapper.SkillViewRow; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.skill.SkillConfigEntity; +import tech.easyflow.manuagent.agent.skill.SkillConfigMapper; +import tech.easyflow.manuagent.agent.skill.SkillViewRow; import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.security.Principal; import java.time.OffsetDateTime; import java.util.Comparator; import java.util.List; @@ -38,8 +36,7 @@ public class SkillService { private final SkillConfigMapper skillMapper; private final PostgresSkillRepository repository; private final SkillPackageReader packageReader; - private final UserService userService; - private final AppProperties properties; + private final AgentProperties properties; /** * 创建 Skill 服务。 @@ -47,19 +44,16 @@ public class SkillService { * @param skillMapper 应用 Skill 配置 Mapper * @param repository AgentScope PostgreSQL 仓库 * @param packageReader Skill 包读取器 - * @param userService 用户服务 * @param properties 应用配置 */ public SkillService( SkillConfigMapper skillMapper, PostgresSkillRepository repository, SkillPackageReader packageReader, - UserService userService, - AppProperties properties) { + AgentProperties properties) { this.skillMapper = skillMapper; this.repository = repository; this.packageReader = packageReader; - this.userService = userService; this.properties = properties; } @@ -147,11 +141,11 @@ public class SkillService { * 导入管理员上传的标准 Skill ZIP。 * * @param file ZIP 文件 - * @param principal 当前用户 + * @param userId 当前操作者 ID * @return 导入后的 Skill */ @Transactional - public SkillView importZip(MultipartFile file, Principal principal) { + public SkillView importZip(MultipartFile file, UUID userId) { if (file.isEmpty()) { throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空"); } @@ -168,7 +162,7 @@ public class SkillService { throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在"); } repository.save(List.of(skillPackage.skill()), false); - UUID userId = userService.requireUserId(principal.getName()); + SkillConfigEntity config = new SkillConfigEntity(); config.setSkillName(name); config.setVersion(skillPackage.version()); diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillViewRow.java similarity index 97% rename from server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java rename to server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillViewRow.java index b8e9043..6a29063 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java +++ b/server/manuagent-agent/src/main/java/tech/easyflow/manuagent/agent/skill/SkillViewRow.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.mapper; +package tech.easyflow.manuagent.agent.skill; import java.time.OffsetDateTime; diff --git a/server/src/main/resources/mapper/AgentEventMapper.xml b/server/manuagent-agent/src/main/resources/mapper/agent/AgentEventMapper.xml similarity index 76% rename from server/src/main/resources/mapper/AgentEventMapper.xml rename to server/manuagent-agent/src/main/resources/mapper/agent/AgentEventMapper.xml index 21d8737..4af0863 100644 --- a/server/src/main/resources/mapper/AgentEventMapper.xml +++ b/server/manuagent-agent/src/main/resources/mapper/agent/AgentEventMapper.xml @@ -2,19 +2,19 @@ - + - + + typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/> + typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/> + typeHandler="tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler"/> @@ -25,12 +25,12 @@ @@ -39,7 +39,7 @@ SELECT payload::text FROM app.agent_event - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND event_type = 'ASK_RESPONDED' AND jsonb_typeof(payload -> 'decisions') = 'array' ORDER BY id DESC diff --git a/server/src/main/resources/mapper/AgentRunMapper.xml b/server/manuagent-agent/src/main/resources/mapper/agent/AgentRunMapper.xml similarity index 86% rename from server/src/main/resources/mapper/AgentRunMapper.xml rename to server/manuagent-agent/src/main/resources/mapper/agent/AgentRunMapper.xml index 4a8414b..5e68691 100644 --- a/server/src/main/resources/mapper/AgentRunMapper.xml +++ b/server/manuagent-agent/src/main/resources/mapper/agent/AgentRunMapper.xml @@ -2,13 +2,13 @@ - + UPDATE app.agent_run SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP - WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'WAITING_INPUT' @@ -17,7 +17,7 @@ SET status = 'INTERRUPTED', pending_interrupt = NULL, error_code = 'USER_STOPPED', error_message = '用户已停止运行', ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'RUNNING' @@ -25,9 +25,9 @@ UPDATE app.agent_run SET status = 'WAITING_INPUT', pending_interrupt = #{interruptJson, jdbcType=OTHER, - typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler}, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'RUNNING' @@ -35,7 +35,7 @@ UPDATE app.agent_run SET status = 'COMPLETED', pending_interrupt = NULL, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'RUNNING' @@ -44,7 +44,7 @@ SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED', error_message = #{message}, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'RUNNING' diff --git a/server/src/main/resources/mapper/ArtifactMapper.xml b/server/manuagent-agent/src/main/resources/mapper/agent/ArtifactMapper.xml similarity index 79% rename from server/src/main/resources/mapper/ArtifactMapper.xml rename to server/manuagent-agent/src/main/resources/mapper/agent/ArtifactMapper.xml index 2788552..fe15fd9 100644 --- a/server/src/main/resources/mapper/ArtifactMapper.xml +++ b/server/manuagent-agent/src/main/resources/mapper/agent/ArtifactMapper.xml @@ -2,26 +2,26 @@ - + SELECT EXISTS( SELECT 1 FROM app.agent_run - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} AND status = 'RUNNING' ) @@ -15,28 +15,28 @@ DELETE FROM app.agent_event - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} DELETE FROM app.artifact - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} DELETE FROM app.project_plan - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} DELETE FROM app.project_file - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} DELETE FROM app.agent_run - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} UPDATE app.project SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP - WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} diff --git a/server/src/main/resources/mapper/ProjectPlanMapper.xml b/server/manuagent-agent/src/main/resources/mapper/agent/ProjectPlanMapper.xml similarity index 72% rename from server/src/main/resources/mapper/ProjectPlanMapper.xml rename to server/manuagent-agent/src/main/resources/mapper/agent/ProjectPlanMapper.xml index 4e2a9af..60bd46a 100644 --- a/server/src/main/resources/mapper/ProjectPlanMapper.xml +++ b/server/manuagent-agent/src/main/resources/mapper/agent/ProjectPlanMapper.xml @@ -1,49 +1,49 @@ - + - SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at FROM app.project_plan - WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler} ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC LIMIT 1 diff --git a/server/src/main/resources/mapper/SkillConfigMapper.xml b/server/manuagent-agent/src/main/resources/mapper/agent/SkillConfigMapper.xml similarity index 91% rename from server/src/main/resources/mapper/SkillConfigMapper.xml rename to server/manuagent-agent/src/main/resources/mapper/agent/SkillConfigMapper.xml index c498eea..e967664 100644 --- a/server/src/main/resources/mapper/SkillConfigMapper.xml +++ b/server/manuagent-agent/src/main/resources/mapper/agent/SkillConfigMapper.xml @@ -1,7 +1,7 @@ - + @@ -9,14 +9,14 @@ c.validation_status, c.validation_message, c.updated_at - SELECT FROM agentscope.agentscope_skills s JOIN app.skill_config c ON c.skill_name = s.name ORDER BY c.source_type, s.name - SELECT FROM agentscope.agentscope_skills s JOIN app.skill_config c ON c.skill_name = s.name diff --git a/server/src/main/resources/prompts/smart-factory-agent-system.md b/server/manuagent-agent/src/main/resources/prompts/smart-factory-agent-system.md similarity index 89% rename from server/src/main/resources/prompts/smart-factory-agent-system.md rename to server/manuagent-agent/src/main/resources/prompts/smart-factory-agent-system.md index fbe9857..ac9106c 100644 --- a/server/src/main/resources/prompts/smart-factory-agent-system.md +++ b/server/manuagent-agent/src/main/resources/prompts/smart-factory-agent-system.md @@ -20,6 +20,8 @@ # 自主执行 +生成 DOCX 前,读取并运行 `/opt/sandbox/docx-example.cjs`,将验证文件写入 `work/tmp/`;复用其中与当前 docx 版本匹配的表格、段落和原生批注 API。`comments` 必须是带 `children` 的对象,表格行的 `children` 必须是一维 TableCell 数组。需要核对其他 API 时读取 `/opt/sandbox/node_modules/docx/dist/index.d.ts`,不要猜测构造参数,也不要通过 `require('docx/package.json')` 获取版本。先用 `node --check` 检查脚本语法,再生成并验证 DOCX。必要步骤失败时立即修复,不能用管道尾部成功或 `|| true` 掩盖失败。 + 1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。 2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill,先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。 3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。 diff --git a/server/src/main/resources/prompts/smart-factory-compaction.md b/server/manuagent-agent/src/main/resources/prompts/smart-factory-compaction.md similarity index 55% rename from server/src/main/resources/prompts/smart-factory-compaction.md rename to server/manuagent-agent/src/main/resources/prompts/smart-factory-compaction.md index cc47cac..810d440 100644 --- a/server/src/main/resources/prompts/smart-factory-compaction.md +++ b/server/manuagent-agent/src/main/resources/prompts/smart-factory-compaction.md @@ -1,6 +1,6 @@ -请将当前会话压缩为可继续执行的结构化工作记忆。必须保留: +请使用简体中文将当前会话压缩为可继续执行的结构化工作记忆。必须保留: -1. 企业名称、申报等级,以及 C(已确认事实)、E(外部依据)、R(规划建议)、P(待实施)、U(待确认)边界。 +1. 企业名称、申报等级,以及 C(企业材料或用户确认的企业事实)、E(企业公开资料,保留来源并标记待企业确认)、R(政策、标准、行业方法和同行案例,仅支撑规划)、P(基于 R 的未来规划、建议目标和测算假设)、U(缺失、冲突或无法核实的企业现状)边界。 2. 已确认并冻结的建设规划,包括建设方向、场景、KPI、投资区间、建设周期和版本。 3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。 4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。 diff --git a/server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/ArtifactServiceQueryTest.java similarity index 92% rename from server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/ArtifactServiceQueryTest.java index ad74a2d..a7075d2 100644 --- a/server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/ArtifactServiceQueryTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.artifact; +package tech.easyflow.manuagent.agent.artifact; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,9 +13,9 @@ import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ArtifactEntity; -import tech.easyflow.manuagent.mapper.ArtifactMapper; -import tech.easyflow.manuagent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.artifact.ArtifactEntity; +import tech.easyflow.manuagent.agent.artifact.ArtifactMapper; +import tech.easyflow.manuagent.agent.project.ProjectFileService; /** * 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围。 diff --git a/server/src/test/java/tech/easyflow/manuagent/artifact/DocxValidatorTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/DocxValidatorTest.java similarity index 99% rename from server/src/test/java/tech/easyflow/manuagent/artifact/DocxValidatorTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/DocxValidatorTest.java index 307a57f..0eeb643 100644 --- a/server/src/test/java/tech/easyflow/manuagent/artifact/DocxValidatorTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/artifact/DocxValidatorTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.artifact; +package tech.easyflow.manuagent.agent.artifact; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceConnectionTest.java similarity index 92% rename from server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceConnectionTest.java index 3acd3d7..82893bf 100644 --- a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceConnectionTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.agent.model; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -7,7 +7,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -16,12 +16,11 @@ import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; /** * 验证模型草稿连接测试的密钥选择和外部请求边界。 @@ -39,9 +38,8 @@ class ModelServiceConnectionTest { context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class)); context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class)); context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class)); - context.registerBean(UserService.class, () -> mock(UserService.class)); context.registerBean(KeyCipher.class, () -> mock(KeyCipher.class)); - context.registerBean(ObjectMapper.class, () -> new ObjectMapper()); + context.registerBean(ObjectMapper.class, () -> tools.jackson.databind.json.JsonMapper.builder().build()); context.register(ModelService.class); context.refresh(); @@ -164,9 +162,8 @@ class ModelServiceConnectionTest { modelMapper, mock(ModelAssignmentMapper.class), mock(AgentRunMapper.class), - mock(UserService.class), - keyCipher, - new ObjectMapper(), + keyCipher, + tools.jackson.databind.json.JsonMapper.builder().build(), httpClient); } diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceQueryTest.java similarity index 83% rename from server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceQueryTest.java index b41c50f..0ea4105 100644 --- a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceQueryTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.agent.model; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -6,7 +6,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import java.time.OffsetDateTime; import java.util.List; @@ -14,13 +14,11 @@ import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.boot.ApplicationRunner; import org.mockito.ArgumentCaptor; -import tech.easyflow.manuagent.auth.UserService; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.mapper.AppUserMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; /** * 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。 @@ -47,9 +45,8 @@ class ModelServiceQueryTest { modelMapper, mock(ModelAssignmentMapper.class), mock(AgentRunMapper.class), - mock(UserService.class), mock(KeyCipher.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); List models = service.list(); diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceValidationTest.java similarity index 84% rename from server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceValidationTest.java index 1ca05b3..f15f476 100644 --- a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/model/ModelServiceValidationTest.java @@ -1,11 +1,11 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.agent.model; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import jakarta.validation.Validation; import jakarta.validation.Validator; import java.util.Map; @@ -13,11 +13,10 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; /** * 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确、可审计的输入边界。 @@ -67,7 +66,7 @@ class ModelServiceValidationTest { Map.of(), Map.of("contextWindow", 8_192)); - assertThatThrownBy(() -> service.save(null, input, () -> "admin")) + assertThatThrownBy(() -> service.save(null, input, UUID.randomUUID())) .as("地址应被拒绝:%s", baseUrl) .isInstanceOfSatisfying(ApiException.class, exception -> assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID")); @@ -80,14 +79,12 @@ class ModelServiceValidationTest { * @return 配置了当前用户的模型服务 */ private ModelService service() { - UserService userService = mock(UserService.class); - when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID()); + return new ModelService( mock(ModelConfigMapper.class), mock(ModelAssignmentMapper.class), mock(AgentRunMapper.class), - userService, mock(KeyCipher.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectFileServiceTest.java similarity index 81% rename from server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectFileServiceTest.java index 951abec..2eafbb9 100644 --- a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectFileServiceTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.project; +package tech.easyflow.manuagent.agent.project; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -8,11 +8,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.mybatisflex.core.query.QueryWrapper; -import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.ProjectFileEntity; -import tech.easyflow.manuagent.mapper.ProjectFileMapper; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.project.ProjectFileEntity; +import tech.easyflow.manuagent.agent.project.ProjectFileMapper; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -37,12 +36,11 @@ class ProjectFileServiceTest { */ @Test void shouldDeleteProjectWorkspace() throws Exception { - AppProperties properties = new AppProperties( + AgentProperties properties = new AgentProperties( temporaryDirectory, Path.of("dashscope"), - "test-master", "admin", "admin", - "runtime:test", "bridge", Duration.ofMinutes(1)); + "test-master", "runtime:test", "bridge", Duration.ofMinutes(1)); ProjectFileService service = new ProjectFileService( - mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties); + mock(ProjectFileMapper.class), mock(ProjectService.class), properties); UUID projectId = UUID.randomUUID(); Path file = service.projectRoot(projectId).resolve("inputs/company.txt"); Files.createDirectories(file.getParent()); @@ -92,7 +90,7 @@ class ProjectFileServiceTest { file.setSizeBytes(1L); when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file)); ProjectFileService service = new ProjectFileService( - mapper, mock(UserService.class), projectService, properties()); + mapper, projectService, properties()); service.list(file.getProjectId()); @@ -111,7 +109,7 @@ class ProjectFileServiceTest { */ private ProjectFileService service() { return new ProjectFileService( - mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties()); + mock(ProjectFileMapper.class), mock(ProjectService.class), properties()); } /** @@ -119,10 +117,9 @@ class ProjectFileServiceTest { * * @return 指向临时数据目录的配置 */ - private AppProperties properties() { - return new AppProperties( + private AgentProperties properties() { + return new AgentProperties( temporaryDirectory, Path.of("dashscope"), - "test-master", "admin", "admin", - "runtime:test", "bridge", Duration.ofMinutes(1)); + "test-master", "runtime:test", "bridge", Duration.ofMinutes(1)); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectServiceQueryTest.java similarity index 88% rename from server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectServiceQueryTest.java index 5021ec2..059a0d9 100644 --- a/server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/project/ProjectServiceQueryTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.project; +package tech.easyflow.manuagent.agent.project; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -7,18 +7,17 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import tech.easyflow.manuagent.auth.UserService; -import tech.easyflow.manuagent.entity.ProjectEntity; -import tech.easyflow.manuagent.entity.ProjectPlanEntity; +import tech.easyflow.manuagent.agent.project.ProjectEntity; +import tech.easyflow.manuagent.agent.project.ProjectPlanEntity; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.mapper.ProjectMapper; -import tech.easyflow.manuagent.mapper.ProjectPlanMapper; +import tech.easyflow.manuagent.agent.project.ProjectMapper; +import tech.easyflow.manuagent.agent.project.ProjectPlanMapper; /** * 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围。 @@ -72,7 +71,7 @@ class ProjectServiceQueryTest { plan.setPlanJson("{invalid-json"); when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan); ProjectService service = new ProjectService( - mock(ProjectMapper.class), planMapper, mock(UserService.class), new ObjectMapper()); + mock(ProjectMapper.class), planMapper, tools.jackson.databind.json.JsonMapper.builder().build()); assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID())) .isInstanceOf(IllegalStateException.class) @@ -101,8 +100,7 @@ class ProjectServiceQueryTest { return new ProjectService( mapper, mock(ProjectPlanMapper.class), - mock(UserService.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); } /** diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentEventServiceQueryTest.java similarity index 85% rename from server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentEventServiceQueryTest.java index f92019c..81459c1 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentEventServiceQueryTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -6,14 +6,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import tech.easyflow.manuagent.entity.AgentEventEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentEventEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; /** * 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义。 @@ -31,7 +31,7 @@ class AgentEventServiceQueryTest { event.setProjectId(UUID.randomUUID()); event.setPayloadJson("{}"); when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event)); - AgentEventService service = new AgentEventService(mapper, new ObjectMapper()); + AgentEventService service = new AgentEventService(mapper, tools.jackson.databind.json.JsonMapper.builder().build()); service.listAfter(event.getProjectId(), 0L, 100); diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionServiceTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionServiceTest.java new file mode 100644 index 0000000..8df92e8 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentExecutionServiceTest.java @@ -0,0 +1,135 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import tools.jackson.databind.ObjectMapper; +import io.agentscope.core.agui.adapter.AguiAgentAdapter; +import io.agentscope.core.agui.event.AguiEvent; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tech.easyflow.manuagent.agent.skill.SkillService; + +/** + * 验证 Agent 事件持久化的精简规则。 + */ +class AgentExecutionServiceTest { + + /** + * Run 在创建时已经固化模型配置 ID;执行和模型重连都必须沿用该 ID, + * 不能在工厂内部重新读取可能已经变化的全局默认模型。 + */ + @Test + void shouldCreateAgentWithModelBoundToRun() { + UUID projectId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + UUID modelId = UUID.randomUUID(); + AgentFactory factory = mock(AgentFactory.class); + AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class); + AguiAgentAdapter adapter = mock(AguiAgentAdapter.class); + SkillService skillService = mock(SkillService.class); + when(handle.adapter()).thenReturn(adapter); + when(adapter.run(any())).thenReturn(Flux.empty()); + when(skillService.enabledNames()).thenReturn(new String[] {"document"}); + when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle); + + ProjectFileService files = mock(ProjectFileService.class); + when(files.projectRoot(projectId)).thenReturn(Path.of("/tmp/project")); + AgentExecutionService service = new AgentExecutionService( + tools.jackson.databind.json.JsonMapper.builder().build(), + factory, + mock(AgentEventService.class), + files, + skillService, new tech.easyflow.manuagent.agent.config.AgentProperties( + Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMinutes(1))); + ProjectService.ProjectView project = mock(ProjectService.ProjectView.class); + when(project.id()).thenReturn(projectId); + when(project.threadId()).thenReturn("project-" + projectId); + AgentRunService.RunView run = new AgentRunService.RunView( + runId, projectId, modelId, "INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null); + + service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false); + + verify(factory).create(eq(projectId), eq(modelId), any(String[].class)); + + when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError( + "project-" + projectId, runId.toString(), "sandbox failed: /tmp/project sk-testsecret123", "AGENT_ERROR"))); + assertThatThrownBy(() -> service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("sandbox failed") + .hasMessageNotContaining("/tmp/project") + .hasMessageNotContaining("sk-testsecret123"); + verify(factory, times(2)).create(eq(projectId), eq(modelId), any(String[].class)); + } + + /** + * 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。 + */ + @Test + void shouldStripInlineImageDataFromPersistedToolResult() { + String content = """ + document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]} + {"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}} + """; + + String result = AgentExecutionService.stripInlineImageData(content); + + assertThat(result).contains("render-1.png"); + assertThat(result).doesNotContain("very-large-base64"); + } + @Test + void shouldRetryConvertedModelErrorsAndEnforceTotalDeadlineEvenWhileStreaming() { + var factory = mock(AgentFactory.class); + var handle = mock(AgentFactory.AgentHandle.class); + var adapter = mock(AguiAgentAdapter.class); + var runtime = mock(AgentRuntimeMiddleware.class); + when(handle.adapter()).thenReturn(adapter); + when(handle.runtime()).thenReturn(runtime); + when(factory.create(any(), any(), any())).thenReturn(handle); + var skills = mock(SkillService.class); + when(skills.enabledNames()).thenReturn(new String[0]); + var files = mock(ProjectFileService.class); + when(files.projectRoot(any())).thenReturn(Path.of("/tmp/project")); + var events = mock(AgentEventService.class); + var properties = new tech.easyflow.manuagent.agent.config.AgentProperties( + Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofSeconds(5)); + var service = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(), + factory, events, files, skills, properties); + var project = mock(ProjectService.ProjectView.class); + var projectId = UUID.randomUUID(); + when(project.id()).thenReturn(projectId); + when(project.threadId()).thenReturn("test"); + var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(), + "INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null); + when(runtime.modelFailure()).thenReturn(new io.agentscope.core.model.transport.HttpTransportException("lost", 503, "")); + when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError("test", "run", "lost", "INTERNAL_ERROR"))) + .thenReturn(Flux.empty()); + service.execute(project, run, "开始", Mono.never(), () -> { }, () -> false); + verify(factory, times(2)).create(any(), any(), any()); + verify(events).append(eq(projectId), eq(run.id()), eq("MODEL_RETRY"), any()); + + var shortService = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(), + factory, events, files, skills, new tech.easyflow.manuagent.agent.config.AgentProperties( + Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMillis(250))); + var shortRun = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(), + "INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null); + when(adapter.run(any())).thenReturn(Flux.interval(java.time.Duration.ofMillis(20)) + .map(i -> new AguiEvent.RunStarted("test", "run"))); + assertThatThrownBy(() -> shortService.execute(project, shortRun, "开始", Mono.never(), () -> { }, () -> false)) + .isInstanceOf(tech.easyflow.manuagent.common.ApiException.class).hasMessageContaining("运行时限"); + verify(handle, times(3)).close(); + assertThatThrownBy(() -> shortService.remainingTime(shortRun)).hasMessageContaining("运行时限"); + } + +} diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunServiceTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunServiceTest.java new file mode 100644 index 0000000..1ab42a3 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunServiceTest.java @@ -0,0 +1,326 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.util.ReflectionTestUtils.invokeMethod; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; +import io.agentscope.core.model.transport.HttpTransportException; +import io.agentscope.core.skill.AgentSkill; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.RejectedExecutionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import tech.easyflow.manuagent.agent.artifact.ArtifactService; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; + +/** + * 验证 Agent Run 的模型重连边界。 + */ +class AgentRunServiceTest { + + @Test + void shouldRepairMalformedJsonInBothAgentOutputPaths() throws Exception { + UUID projectId = UUID.randomUUID(); + var mapper = tools.jackson.databind.json.JsonMapper.builder().build(); + tools.jackson.core.JacksonException malformed; + try { + mapper.readTree("{invalid"); + throw new AssertionError("Malformed JSON accepted"); + } catch (tools.jackson.core.JacksonException exception) { + malformed = exception; + } + var repaired = mapper.createObjectNode().put("summary", "已修复"); + var output = mock(AgentOutputService.class); + when(output.readMaterialCheck(projectId)).thenThrow(malformed).thenReturn(repaired); + when(output.readProposedPlan(projectId)).thenThrow(malformed).thenReturn(repaired); + var execution = mock(AgentExecutionService.class); + var store = mock(AgentRunStore.class); + var project = mock(ProjectService.ProjectView.class); + when(project.id()).thenReturn(projectId); + var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(), + "INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null); + var service = new AgentRunService(mock(AgentRunMapper.class), mapper, execution, output, + store, mock(AgentEventService.class), mock(ProjectService.class), + mock(ProjectFileService.class), mock(ArtifactService.class), + mock(ExecutorService.class), mock(TransactionTemplate.class), 2); + for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) { + Object result = org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, method, project, run); + assertThat(result).isSameAs(repaired); + } + verify(execution, org.mockito.Mockito.times(2)).execute( + org.mockito.ArgumentMatchers.eq(project), org.mockito.ArgumentMatchers.eq(run), + org.mockito.ArgumentMatchers.contains("修复"), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(store, org.mockito.Mockito.times(2)).ensureRunning(run.id()); + } + + /** + * 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run, + * 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。 + */ + @ParameterizedTest + @ValueSource(ints = {1, 2}) + void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdatedAtCapacity(int limit) { + UUID projectId = UUID.randomUUID(); + UUID modelId = UUID.randomUUID(); + UUID currentRunId = UUID.randomUUID(); + UUID replacementRunId = UUID.randomUUID(); + OffsetDateTime now = OffsetDateTime.now(); + ProjectService.ProjectView project = new ProjectService.ProjectView( + projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now); + AgentRunService.RunView current = new AgentRunService.RunView( + currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null); + AgentRunService.RunView replacement = new AgentRunService.RunView( + replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null); + + AgentRunMapper runMapper = mock(AgentRunMapper.class); + AgentRunStore runStore = mock(AgentRunStore.class); + ProjectService projectService = mock(ProjectService.class); + + when(projectService.require(projectId)).thenReturn(project); + + when(runStore.latest(projectId)).thenReturn(current); + when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK"); + when(runMapper.interruptRunning(currentRunId)).thenReturn(1); + when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement); + + AgentRunService service = new AgentRunService( + runMapper, + tools.jackson.databind.json.JsonMapper.builder().build(), + mock(AgentExecutionService.class), + mock(AgentOutputService.class), + runStore, + mock(AgentEventService.class), + projectService, + mock(ProjectFileService.class), + mock(ArtifactService.class), + mock(ExecutorService.class), + mock(TransactionTemplate.class), limit); + + // 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。 + TransactionSynchronizationManager.initSynchronization(); + try { + for (int i = 0; i < limit; i++) { + invokeMethod(service, "afterCommit", i == 0 ? currentRunId : UUID.randomUUID(), (Runnable) () -> {}); + } + assertThat(service.switchModel(projectId, modelId, UUID.randomUUID())).isEqualTo(replacement); + verify(runMapper).interruptRunning(currentRunId); + verify(runStore).create(projectId, "RESUME", currentRunId, modelId); + } finally { + TransactionSynchronizationManager.getSynchronizations().forEach(sync -> + sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void shouldWaitForPreviousTaskCleanupBeforeStartingReplacement() throws Exception { + var queue = new LinkedBlockingQueue(); + var executor = mock(ExecutorService.class); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + queue.add(call.getArgument(0)); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + var service = schedulingService(executor, mock(AgentRunStore.class)); + var previous = UUID.randomUUID(); + var replacementStarted = new CountDownLatch(1); + transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true); + Runnable previousTask = queue.remove(); + transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), + (Runnable) replacementStarted::countDown, previous), true); + var replacement = Thread.startVirtualThread(queue.remove()); + try { + assertThat(replacementStarted.await(200, TimeUnit.MILLISECONDS)).isFalse(); + assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit", + UUID.randomUUID(), (Runnable) () -> {}), true)).hasMessageContaining("已达上限"); + } finally { + previousTask.run(); + replacement.join(5_000); + } + assertThat(replacement.isAlive()).isFalse(); + assertThat(replacementStarted.getCount()).isZero(); + // 接续完成后恢复一个名额。 + transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void shouldRetainPredecessorCapacityWhenReplacementRollsBackOrIsRejected(boolean rejected) { + var executor = mock(ExecutorService.class); + var queue = new LinkedBlockingQueue(); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + queue.add(call.getArgument(0)); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + var store = mock(AgentRunStore.class); + var previous = UUID.randomUUID(); + var replacement = UUID.randomUUID(); + when(store.require(replacement)).thenReturn(new AgentRunService.RunView(replacement, + UUID.randomUUID(), UUID.randomUUID(), "RESUME", "RUNNING", null, null, OffsetDateTime.now(), null)); + var service = schedulingService(executor, store); + transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true); + Runnable previousTask = queue.remove(); + if (rejected) when(executor.submit(any(Runnable.class))).thenThrow(new RejectedExecutionException("test")); + transaction(() -> invokeMethod(service, "afterCommit", replacement, (Runnable) () -> {}, previous), rejected); + assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit", + UUID.randomUUID(), (Runnable) () -> {}), false)).hasMessageContaining("已达上限"); + previousTask.run(); + transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false); + } + + private static AgentRunService schedulingService(ExecutorService executor, AgentRunStore store) { + return new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(), + mock(AgentExecutionService.class), mock(AgentOutputService.class), store, + mock(AgentEventService.class), mock(ProjectService.class), mock(ProjectFileService.class), + mock(ArtifactService.class), executor, mock(TransactionTemplate.class), 1); + } + + @Test + void shouldShareCapacityWhenRestartingMaterialCheckWhileStoppedRunIsClosing() { + UUID project = UUID.randomUUID(); + var previous = new AgentRunService.RunView(UUID.randomUUID(), project, UUID.randomUUID(), + "INITIAL", "INTERRUPTED", null, null, OffsetDateTime.now(), OffsetDateTime.now()); + var next = new AgentRunService.RunView(UUID.randomUUID(), project, previous.modelConfigId(), + "INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null); + var store = mock(AgentRunStore.class); + when(store.latest(project)).thenReturn(previous); + when(store.create(project, "INITIAL", null)).thenReturn(next); + var service = schedulingService(mock(ExecutorService.class), store); + transaction(() -> { + invokeMethod(service, "afterCommit", previous.id(), (Runnable) () -> {}); + assertThat(service.startMaterialCheck(project, UUID.randomUUID())).isEqualTo(next); + }, false); + } + + private static void transaction(Runnable action, boolean commit) { + TransactionSynchronizationManager.initSynchronization(); + int status = TransactionSynchronization.STATUS_ROLLED_BACK; + try { + action.run(); + if (commit) { + status = TransactionSynchronization.STATUS_COMMITTED; + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); + } + } finally { + for (var sync : TransactionSynchronizationManager.getSynchronizations()) sync.afterCompletion(status); + TransactionSynchronizationManager.clearSynchronization(); + } + } + + /** + * 网络故障和服务端错误允许重连,参数错误保持原始失败。 + */ + @Test + void shouldRetryOnlyRecoverableModelFailures() { + assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5); + assertThat(AgentExecutionService.isRetryableModelFailure( + new RuntimeException(new HttpTransportException("disconnected")))).isTrue(); + assertThat(AgentExecutionService.isRetryableModelFailure( + new HttpTransportException("unavailable", 503, ""))).isTrue(); + assertThat(AgentExecutionService.isRetryableModelFailure( + new HttpTransportException("invalid request", 400, ""))).isFalse(); + assertThat(AgentExecutionService.isRetryableModelFailure( + new IllegalArgumentException("invalid prompt"))).isFalse(); + } + + /** + * 验证上下文压缩只按模型窗口 90% Token 触发。 + */ + @Test + void shouldCompactAtNinetyPercentTokensOnly() { + var config = AgentFactory.compactionFor(100_000, "summary"); + + assertThat(config.getTriggerTokens()).isEqualTo(90_000); + assertThat(config.getTriggerMessages()).isZero(); + assertThat(config.getSummaryPrompt()).isEqualTo("summary"); + } + + /** + * 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。 + */ + @Test + void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() { + AgentSkill source = new AgentSkill( + Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"), + "使用说明", + Map.of("references/guide.md", "参考内容"), + "imported", + Path.of("/skills/pdf")); + + AgentSkill canonical = AgentFactory.canonicalSkill(source); + + assertThat(canonical.getSkillId()).isEqualTo("pdf"); + assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata()); + assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent()); + assertThat(canonical.getResources()).isEqualTo(source.getResources()); + assertThat(canonical.getSource()).isEqualTo("imported"); + assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir()); + } + + /** + * 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。 + */ + @Test + void shouldNormalizePlanningYearArray() { + ObjectNode plan = tools.jackson.databind.json.JsonMapper.builder().build().createObjectNode(); + plan.putArray("planningYears").add("2026").add("2027"); + + AgentOutputService.normalizePlanningYears(plan); + + assertThat(plan.path("planningYears").asInt()).isEqualTo(2); + assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027"); + } + @Test + void shouldBoundRepairsAndReleaseCapacityOnRollback() throws Exception { + var projectId = UUID.randomUUID(); + var output = mock(AgentOutputService.class); + when(output.readMaterialCheck(projectId)).thenThrow(new java.io.IOException("broken JSON")); + when(output.readProposedPlan(projectId)).thenThrow(new java.io.IOException("broken JSON")); + var execution = mock(AgentExecutionService.class); + var project = mock(ProjectService.ProjectView.class); + when(project.id()).thenReturn(projectId); + var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(), + "INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null); + var service = new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(), + execution, output, mock(AgentRunStore.class), mock(AgentEventService.class), mock(ProjectService.class), + mock(ProjectFileService.class), mock(ArtifactService.class), mock(ExecutorService.class), mock(TransactionTemplate.class), 1); + for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod( + service, method, project, run)).hasMessageContaining("连续修复失败"); + } + verify(execution, org.mockito.Mockito.times(6)).execute(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + TransactionSynchronizationManager.initSynchronization(); + try { + Runnable task = () -> { }; + org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod( + service, "afterCommit", UUID.randomUUID(), task)).hasMessageContaining("已达上限"); + var synchronization = TransactionSynchronizationManager.getSynchronizations().getFirst(); + synchronization.afterCompletion(org.springframework.transaction.support.TransactionSynchronization.STATUS_ROLLED_BACK); + org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + +} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunStoreQueryTest.java similarity index 88% rename from server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunStoreQueryTest.java index 4b4514d..985bc79 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRunStoreQueryTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -7,17 +7,17 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import tech.easyflow.manuagent.entity.AgentRunEntity; +import tech.easyflow.manuagent.agent.runtime.AgentRunEntity; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; /** * 验证 Agent 执行热路径只读取判断运行状态所需的最小列。 @@ -37,7 +37,7 @@ class AgentRunStoreQueryTest { runMapper, mock(AgentEventMapper.class), mock(ModelConfigMapper.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); store.ensureRunning(UUID.randomUUID()); @@ -61,7 +61,7 @@ class AgentRunStoreQueryTest { runMapper, mock(AgentEventMapper.class), mock(ModelConfigMapper.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null)) .isInstanceOfSatisfying(ApiException.class, exception -> { @@ -99,7 +99,7 @@ class AgentRunStoreQueryTest { runMapper, mock(AgentEventMapper.class), modelMapper, - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId); @@ -118,7 +118,7 @@ class AgentRunStoreQueryTest { mock(AgentRunMapper.class), mock(AgentEventMapper.class), mock(ModelConfigMapper.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); UUID runId = UUID.randomUUID(); assertThatThrownBy(() -> store.require(runId)) diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddlewareTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddlewareTest.java new file mode 100644 index 0000000..cfea679 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentRuntimeMiddlewareTest.java @@ -0,0 +1,51 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agui.adapter.AguiAdapterConfig; +import io.agentscope.core.agui.adapter.AguiAgentAdapter; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.agui.model.AguiMessage; +import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.transport.HttpTransportException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +class AgentRuntimeMiddlewareTest { + @Test + void shouldKeepOriginalFailureAcrossRealAguiConversionAndRefreshLanguage() { + var model = mock(Model.class); + when(model.getModelName()).thenReturn("test-model"); + var failure = new HttpTransportException("unavailable", 503, ""); + var inputs = new ArrayList>(); + when(model.stream(any(), any(), any())).thenAnswer(call -> { + inputs.add(call.getArgument(0)); + return inputs.size() == 1 ? Flux.error(failure) : Flux.just(ChatResponse.builder() + .content(List.of(TextBlock.builder().text("已完成").build())).build()); + }); + var middleware = new AgentRuntimeMiddleware(); + var agent = ReActAgent.builder().name("test-agent").model(model).middleware(middleware).build(); + var adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.builder().build()); + var input = RunAgentInput.builder().threadId("language-test").runId("run-1") + .messages(List.of(AguiMessage.userMessage("m1", "Please inspect the file."))).build(); + var events = adapter.run(input).collectList().block(); + assertThat(events).anyMatch(AguiEvent.RunError.class::isInstance); + assertThat(middleware.modelFailure()).isSameAs(failure); + adapter.run(input).blockLast(); + assertThat(middleware.modelFailure()).isNull(); + assertThat(inputs).hasSize(2); + for (var messages : inputs) { + assertThat(messages.getLast().getTextContent()).contains("简体中文", "英文 Skill", "专有名词"); + assertThat(messages.stream().filter(msg -> msg.getTextContent().contains(""))).hasSize(1); + } + } +} diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentStateFilesTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentStateFilesTest.java new file mode 100644 index 0000000..97156de --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/AgentStateFilesTest.java @@ -0,0 +1,92 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.state.JsonFileAgentStateStore; +import io.agentscope.core.state.AgentState; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.sandbox.SandboxIsolationKey; +import io.agentscope.harness.agent.sandbox.SessionSandboxStateStore; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState; +import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec; +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class AgentStateFilesTest { + @TempDir Path root; + + @Test + void shouldPreserveLegacySessionAndReloadFromPersistentRoot() throws Exception { + UUID projectId = UUID.randomUUID(); + String session = "project-" + projectId; + Path legacy = root.resolve("legacy"); + Path source = legacy.resolve("smart-factory-agent/__anon__").resolve(session); + Files.createDirectories(source); + Files.writeString(source.resolve("memory_messages.jsonl"), "历史记忆\n"); + var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent")); + oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build()); + Path target = AgentStateFiles.prepare(root.resolve("data"), legacy, projectId); + var restored = new JsonFileAgentStateStore(target); + assertThat(restored.get(null, session, "agent_state", AgentState.class)).isPresent(); + assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl"))) + .isEqualTo("历史记忆\n"); + assertThat(source.resolve("agent_state.json")).exists(); + Files.writeString(source.resolve("memory_messages.jsonl"), "旧进程后来修改的内容"); + AgentStateFiles.prepare(root.resolve("data"), legacy, projectId); + assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl"))) + .isEqualTo("历史记忆\n"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void shouldRestoreSandboxSnapshotAndBackfillAlreadyMigratedSessions(boolean migratedBefore) throws Exception { + UUID projectId = UUID.randomUUID(); + String session = "project-" + projectId; + Path legacy = root.resolve("legacy"); + Path data = root.resolve("data"); + var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent")); + var key = SandboxIsolationKey.resolve(IsolationScope.SESSION, + RuntimeContext.builder().sessionId(session).build(), "smart-factory-agent").orElseThrow(); + var oldSandbox = new SessionSandboxStateStore(oldStore, "smart-factory-agent"); + oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build()); + if (migratedBefore) { + // 模拟上一版本已经复制会话,但尚未复制沙箱独立状态槽。 + AgentStateFiles.prepare(data, legacy, projectId); + } + + var client = new DockerSandboxClient(); + var state = new DockerSandboxState(); + state.setSessionId(UUID.randomUUID().toString()); + state.setImage("agent-sandbox:0.1"); + state.setSnapshot(new LocalSnapshotSpec(data.resolve("sandbox-snapshots")).build(state.getSessionId())); + byte[] archive = "snapshot-fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8); + state.getSnapshot().persist(new ByteArrayInputStream(archive)); + String saved = client.serializeState(state); + oldSandbox.save(key, saved); + + Path target = AgentStateFiles.prepare(data, legacy, projectId); + var restoredStore = new JsonFileAgentStateStore(target); + var restoredSandbox = new SessionSandboxStateStore(restoredStore, "smart-factory-agent"); + assertThat(restoredStore.get(null, session, "agent_state", AgentState.class)).isPresent(); + assertThat(restoredSandbox.load(key)).contains(saved); + try (var input = client.deserializeState(restoredSandbox.load(key).orElseThrow()).getSnapshot().restore()) { + assertThat(input.readAllBytes()).isEqualTo(archive); + } + assertThat(oldSandbox.load(key)).contains(saved); + + restoredSandbox.save(key, "newer-state"); + AgentStateFiles.prepare(data, legacy, projectId); + assertThat(restoredSandbox.load(key)).contains("newer-state"); + restoredSandbox.delete(key); + AgentStateFiles.prepare(data, legacy, projectId); + assertThat(restoredSandbox.load(key)).isEmpty(); + } +} diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/DocumentViewToolTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/DocumentViewToolTest.java new file mode 100644 index 0000000..50409b5 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/DocumentViewToolTest.java @@ -0,0 +1,51 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import tools.jackson.databind.ObjectMapper; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.tool.Toolkit; +import org.junit.jupiter.api.Test; +import java.util.UUID; +import java.io.IOException; +import org.springframework.core.io.ByteArrayResource; +import tech.easyflow.manuagent.agent.project.ProjectFileService; + +/** + * 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。 + */ +class DocumentViewToolTest { + + /** + * 验证多视图参数能够进入模型工具定义。 + */ + @Test + void shouldRegisterMultiViewSchema() { + Toolkit toolkit = new Toolkit(); + + toolkit.registerTool(new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(), + mock(ProjectFileService.class), UUID.randomUUID())); + + ToolSchema schema = toolkit.getToolSchemas().getFirst(); + assertThat(schema.getName()).isEqualTo("document_view"); + assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range"); + } + + @Test + void shouldPreserveLargePreviewBytesAndRejectIncompleteImages() throws IOException { + UUID projectId = UUID.randomUUID(); + String path = "work/tmp/document-view/test/render-1.png"; + byte[] bytes = new byte[700_000]; + new java.util.Random(1).nextBytes(bytes); + ProjectFileService files = mock(ProjectFileService.class); + when(files.preview(projectId, path)).thenReturn( + new ProjectFileService.Preview("image/png", new ByteArrayResource(bytes))); + var tool = new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(), files, projectId); + assertThat(tool.readPreview(path, bytes.length)).isEqualTo(bytes); + assertThatThrownBy(() -> tool.readPreview(path, bytes.length + 1)).isInstanceOf(IOException.class); + assertThatThrownBy(() -> tool.readPreview(path, 5 * 1024 * 1024)).isInstanceOf(IOException.class); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/PagedReadFileToolTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileToolTest.java similarity index 92% rename from server/src/test/java/tech/easyflow/manuagent/agent/PagedReadFileToolTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileToolTest.java index a5102f2..001da4b 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/PagedReadFileToolTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/PagedReadFileToolTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.agent.runtime; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -7,7 +7,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; @@ -35,7 +35,7 @@ class PagedReadFileToolTest { 0, false)); PagedReadFileTool tool = new PagedReadFileTool( - filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper()); + filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build()); String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2); @@ -56,7 +56,7 @@ class PagedReadFileToolTest { 0, true)); PagedReadFileTool tool = new PagedReadFileTool( - filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper()); + filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build()); String result = tool.readFile(RuntimeContext.empty(), "work/report.txt", 0, 10); diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClientTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClientTest.java new file mode 100644 index 0000000..ccf2d48 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/RecoverableDockerClientTest.java @@ -0,0 +1,62 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import org.junit.jupiter.api.Test; + +class RecoverableDockerClientTest { + @Test + void shouldRecoverOnlyTheSdkOwnedCanonicalSessionContainer() { + var client = new RecoverableDockerClient("agent-sandbox:0.1"); + var state = new DockerSandboxState(); + state.setWorkspaceSpec(new WorkspaceSpec()); + state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f"); + state.setContainerName("agentscope-sandbox-" + state.getSessionId()); + state.setContainerId("obsolete-id"); + client.resume(state); + assertThat(state.getContainerId()).isEqualTo(state.getContainerName()); + + state.setContainerOwned(false); + state.setContainerId("external-id"); + client.resume(state); + assertThat(state.getContainerId()).isEqualTo("external-id"); + + state.setContainerOwned(true); + state.setContainerName("other-project"); + client.resume(state); + assertThat(state.getContainerId()).isEqualTo("external-id"); + + state.setSessionId("untrusted-name"); + state.setContainerName("agentscope-sandbox-untrusted-name"); + client.resume(state); + assertThat(state.getContainerId()).isEqualTo("external-id"); + } + + @Test + void shouldMigrateOnlyOwnedLegacyImagesToTheConfiguredImage() { + var client = new RecoverableDockerClient("registry.example/agent-sandbox:0.1"); + var state = new DockerSandboxState(); + var workspace = new WorkspaceSpec(); + state.setWorkspaceSpec(workspace); + state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f"); + for (String tag : new String[]{"0.1.0", "pre-font-fix"}) { + state.setImage("smart-factory-agent-runtime:" + tag); + var sandbox = client.resume(state); + assertThat(sandbox.getState()).isSameAs(state); + assertThat(state.getImage()).isEqualTo("registry.example/agent-sandbox:0.1"); + assertThat(state.getWorkspaceSpec()).isSameAs(workspace); + assertThat(state.getSessionId()).isEqualTo("08726648-85b1-42c0-a9c8-161e6b44150f"); + } + + state.setImage("custom-sandbox:latest"); + client.resume(state); + assertThat(state.getImage()).isEqualTo("custom-sandbox:latest"); + + state.setContainerOwned(false); + state.setImage("smart-factory-agent-runtime:0.1.0"); + client.resume(state); + assertThat(state.getImage()).isEqualTo("smart-factory-agent-runtime:0.1.0"); + } +} diff --git a/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/SandboxToolsTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/SandboxToolsTest.java new file mode 100644 index 0000000..5ecb426 --- /dev/null +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/runtime/SandboxToolsTest.java @@ -0,0 +1,60 @@ +package tech.easyflow.manuagent.agent.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SandboxToolsTest { + @TempDir Path root; + + @Test + void shouldExecuteRealEditsAndPreserveShellFailures() throws Exception { + var filesystem = mock(AbstractSandboxFilesystem.class); + when(filesystem.execute(any(), anyString(), anyInt())).thenAnswer(call -> { + Process process = new ProcessBuilder("bash", "-c", call.getArgument(1, String.class)) + .directory(root.toFile()).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + return new ExecuteResponse(output, process.waitFor(), false); + }); + var sandboxTools = new SandboxTools(filesystem, tools.jackson.databind.json.JsonMapper.builder().build()); + Path file = root.resolve("含 ' 引号.txt"); + Files.writeString(file, "中文原文\n重复 重复\n"); + var edited = sandboxTools.editFile(null, file.getFileName().toString(), "中文原文", "引号'、反引号`、$(false)\n第二行", false); + assertThat(edited.get("success")).isEqualTo(true); + assertThat(Files.readString(file)).startsWith("引号'、反引号`、$(false)\n第二行"); + String before = Files.readString(file); + assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", false).get("success")).isEqualTo(false); + assertThat(Files.readString(file)).isEqualTo(before); + assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", true).get("replacements")).isEqualTo(2); + assertThat(sandboxTools.editFile(null, "../outside", "x", "y", false).get("success")).isEqualTo(false); + Files.createSymbolicLink(root.resolve("outside"), root.getParent()); + assertThat(sandboxTools.editFile(null, "outside/no-file", "x", "y", false).get("success")).isEqualTo(false); + + var failed = sandboxTools.execute(null, "false | tail -1\ntouch should-not-exist", null, 5); + assertThat(failed.get("success")).isEqualTo(false); + assertThat(failed.get("exitCode")).isEqualTo(1); + assertThat(root.resolve("should-not-exist")).doesNotExist(); + assertThat(sandboxTools.execute(null, "printf '正常'", null, 5).get("output")).isEqualTo("正常"); + // 通过 SDK 的反射注册和调用链验证工具覆盖,避免只测 Java 方法而漏掉协议接入。 + var toolkit = new io.agentscope.core.tool.Toolkit(); + toolkit.registerTool(sandboxTools); + var input = java.util.Map.of("path", file.getFileName().toString(), "old_string", "第二行", "new_string", "SDK 编辑成功"); + var call = io.agentscope.core.message.ToolUseBlock.builder().id("edit-check").name("edit_file") + .input(input).content(tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString(input)).build(); + var result = toolkit.callTool(io.agentscope.core.tool.ToolCallParam.builder().toolUseBlock(call).build()).block(); + assertThat(result).isNotNull(); + assertThat(((io.agentscope.core.message.TextBlock) result.getOutput().getFirst()).getText()).contains("\"success\":true"); + assertThat(Files.readString(file)).contains("SDK 编辑成功"); + + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/skill/SkillArchiveTest.java b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/skill/SkillArchiveTest.java similarity index 98% rename from server/src/test/java/tech/easyflow/manuagent/skill/SkillArchiveTest.java rename to server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/skill/SkillArchiveTest.java index 61e55cc..55736e5 100644 --- a/server/src/test/java/tech/easyflow/manuagent/skill/SkillArchiveTest.java +++ b/server/manuagent-agent/src/test/java/tech/easyflow/manuagent/agent/skill/SkillArchiveTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.skill; +package tech.easyflow.manuagent.agent.skill; import static org.assertj.core.api.Assertions.assertThat; diff --git a/server/manuagent-common/pom.xml b/server/manuagent-common/pom.xml new file mode 100644 index 0000000..ede912c --- /dev/null +++ b/server/manuagent-common/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + tech.easyflow + manuagent-parent + 0.1.0 + + manuagent-common + + + org.springframework + spring-web + + + org.mybatis + mybatis + + + diff --git a/server/src/main/java/tech/easyflow/manuagent/common/ApiError.java b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/ApiError.java similarity index 100% rename from server/src/main/java/tech/easyflow/manuagent/common/ApiError.java rename to server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/ApiError.java diff --git a/server/src/main/java/tech/easyflow/manuagent/common/ApiException.java b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/ApiException.java similarity index 100% rename from server/src/main/java/tech/easyflow/manuagent/common/ApiException.java rename to server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/ApiException.java diff --git a/server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/JsonbStringTypeHandler.java similarity index 97% rename from server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java rename to server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/JsonbStringTypeHandler.java index 01fca48..bcdbff9 100644 --- a/server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java +++ b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/JsonbStringTypeHandler.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.typehandler; +package tech.easyflow.manuagent.common.typehandler; import java.sql.CallableStatement; import java.sql.PreparedStatement; diff --git a/server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/UuidTypeHandler.java similarity index 98% rename from server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java rename to server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/UuidTypeHandler.java index 1b28fe4..2c7c293 100644 --- a/server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java +++ b/server/manuagent-common/src/main/java/tech/easyflow/manuagent/common/typehandler/UuidTypeHandler.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.typehandler; +package tech.easyflow.manuagent.common.typehandler; import java.sql.CallableStatement; import java.sql.PreparedStatement; diff --git a/server/manuagent-web/pom.xml b/server/manuagent-web/pom.xml new file mode 100644 index 0000000..4012794 --- /dev/null +++ b/server/manuagent-web/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + tech.easyflow + manuagent-parent + 0.1.0 + + manuagent-web + + + tech.easyflow + manuagent-common + + + tech.easyflow + manuagent-agent + + + tech.easyflow + manuagent-admin + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-flyway + + + com.mybatis-flex + mybatis-flex-spring-boot4-starter + + + tools.jackson.core + jackson-databind + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.security + spring-security-test + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + + + manuagent-web + + + src/main/resources + false + + + ${project.basedir}/../src/main/resources + false + db/migration/** + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/server/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java similarity index 75% rename from server/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java index e2a93f5..7477206 100644 --- a/server/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/ManuAgentApplication.java @@ -1,6 +1,6 @@ package tech.easyflow.manuagent; -import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.agent.config.AgentProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -9,7 +9,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties * 智造申报 Agent 服务入口。 */ @SpringBootApplication -@EnableConfigurationProperties(AppProperties.class) +@EnableConfigurationProperties({AgentProperties.class, tech.easyflow.manuagent.admin.config.AdminProperties.class}) public class ManuAgentApplication { /** diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/agent/AgentController.java similarity index 85% rename from server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/agent/AgentController.java index 75e0851..10e2cfb 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/agent/AgentController.java @@ -1,9 +1,13 @@ -package tech.easyflow.manuagent.agent; +package tech.easyflow.manuagent.web.agent; -import com.fasterxml.jackson.databind.JsonNode; +import tech.easyflow.manuagent.agent.runtime.AgentRunService; +import tech.easyflow.manuagent.agent.runtime.AgentEventService; + +import tools.jackson.databind.JsonNode; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.security.Principal; +import tech.easyflow.manuagent.admin.auth.UserService; import java.util.List; import java.util.UUID; import org.springframework.http.MediaType; @@ -24,6 +28,8 @@ import reactor.core.publisher.Flux; @RequestMapping("/api/projects/{projectId}") public class AgentController { + private final UserService userService; + private final AgentRunService runService; private final AgentEventService eventService; @@ -33,7 +39,8 @@ public class AgentController { * @param runService Run 服务 * @param eventService 事件服务 */ - public AgentController(AgentRunService runService, AgentEventService eventService) { + public AgentController(UserService userService, AgentRunService runService, AgentEventService eventService) { + this.userService = userService; this.runService = runService; this.eventService = eventService; } @@ -47,7 +54,7 @@ public class AgentController { */ @PostMapping("/runs/material-check") public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) { - return runService.startMaterialCheck(projectId, principal); + return runService.startMaterialCheck(projectId, userService.requireUserId(principal.getName())); } /** @@ -63,7 +70,7 @@ public class AgentController { @PathVariable UUID projectId, @RequestBody JsonNode response, Principal principal) { - return runService.confirmMaterials(projectId, response, principal); + return runService.confirmMaterials(projectId, response, userService.requireUserId(principal.getName())); } /** @@ -86,7 +93,7 @@ public class AgentController { */ @PostMapping("/runs/stop") public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) { - return runService.stop(projectId, principal); + return runService.stop(projectId, userService.requireUserId(principal.getName())); } /** @@ -101,7 +108,7 @@ public class AgentController { @PathVariable UUID projectId, @Valid @RequestBody(required = false) ResumeInput input, Principal principal) { - return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal); + return runService.resume(projectId, input == null ? null : input.modelConfigId(), userService.requireUserId(principal.getName())); } /** @@ -117,7 +124,7 @@ public class AgentController { @PathVariable UUID projectId, @Valid @RequestBody ResumeInput input, Principal principal) { - return runService.switchModel(projectId, input.modelConfigId(), principal); + return runService.switchModel(projectId, input.modelConfigId(), userService.requireUserId(principal.getName())); } /** diff --git a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/artifact/ArtifactController.java similarity index 94% rename from server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/artifact/ArtifactController.java index 65b1613..1c4990a 100644 --- a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/artifact/ArtifactController.java @@ -1,4 +1,6 @@ -package tech.easyflow.manuagent.artifact; +package tech.easyflow.manuagent.web.artifact; + +import tech.easyflow.manuagent.agent.artifact.ArtifactService; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; diff --git a/server/src/main/java/tech/easyflow/manuagent/auth/AuthController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/auth/AuthController.java similarity index 98% rename from server/src/main/java/tech/easyflow/manuagent/auth/AuthController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/auth/AuthController.java index c123666..a5ada83 100644 --- a/server/src/main/java/tech/easyflow/manuagent/auth/AuthController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/auth/AuthController.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.auth; +package tech.easyflow.manuagent.web.auth; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/server/src/main/java/tech/easyflow/manuagent/common/GlobalExceptionHandler.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandler.java similarity index 97% rename from server/src/main/java/tech/easyflow/manuagent/common/GlobalExceptionHandler.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandler.java index a2dea98..4971039 100644 --- a/server/src/main/java/tech/easyflow/manuagent/common/GlobalExceptionHandler.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandler.java @@ -1,4 +1,7 @@ -package tech.easyflow.manuagent.common; +package tech.easyflow.manuagent.web.common; + +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.common.ApiError; import jakarta.validation.ConstraintViolationException; import java.util.UUID; diff --git a/server/src/main/java/tech/easyflow/manuagent/common/TraceIdFilter.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/TraceIdFilter.java similarity index 97% rename from server/src/main/java/tech/easyflow/manuagent/common/TraceIdFilter.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/TraceIdFilter.java index 98d526d..02317d4 100644 --- a/server/src/main/java/tech/easyflow/manuagent/common/TraceIdFilter.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/common/TraceIdFilter.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.common; +package tech.easyflow.manuagent.web.common; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/server/src/main/java/tech/easyflow/manuagent/config/InfraConfig.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/InfraConfig.java similarity index 95% rename from server/src/main/java/tech/easyflow/manuagent/config/InfraConfig.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/InfraConfig.java index 01c43bf..b40227c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/InfraConfig.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/InfraConfig.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.web.config; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; diff --git a/server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/MyBatisFlexConfiguration.java similarity index 59% rename from server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/MyBatisFlexConfiguration.java index 751eebb..11fd09e 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/MyBatisFlexConfiguration.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.web.config; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Configuration; @@ -6,11 +6,12 @@ import org.springframework.context.annotation.Configuration; /** * 配置 MyBatis-Flex Mapper 扫描。 * - *

业务 Mapper 统一放在 {@code tech.easyflow.manuagent.mapper} 包中。数据库连接、连接池和 + *

业务 Mapper 随 Agent/Admin 领域装配。数据库连接、连接池和 * Spring 事务管理器继续复用 Spring Boot 已配置的数据源,使 MyBatis-Flex 的 Mapper 调用、 * 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务。

*/ @Configuration -@MapperScan("tech.easyflow.manuagent.mapper") +@MapperScan(basePackages = {"tech.easyflow.manuagent.agent", "tech.easyflow.manuagent.admin"}, + annotationClass = org.apache.ibatis.annotations.Mapper.class) public class MyBatisFlexConfiguration { } diff --git a/server/src/main/java/tech/easyflow/manuagent/config/SecurityConfig.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/SecurityConfig.java similarity index 96% rename from server/src/main/java/tech/easyflow/manuagent/config/SecurityConfig.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/SecurityConfig.java index f72ac20..5183efa 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/SecurityConfig.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/SecurityConfig.java @@ -1,8 +1,8 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.web.config; -import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.admin.auth.UserService; import tech.easyflow.manuagent.common.ApiError; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import java.time.Instant; import java.util.UUID; import org.springframework.context.annotation.Bean; diff --git a/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/StartupConfiguration.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/StartupConfiguration.java new file mode 100644 index 0000000..b8150a0 --- /dev/null +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/config/StartupConfiguration.java @@ -0,0 +1,19 @@ +package tech.easyflow.manuagent.web.config; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tech.easyflow.manuagent.admin.auth.UserService; +import tech.easyflow.manuagent.agent.runtime.RunRecoveryService; + +/** Flyway 完成后由唯一应用入口执行恢复与账号初始化,保持原先顺序。 */ +@Configuration(proxyBeanMethods = false) +public class StartupConfiguration { + @Bean + ApplicationRunner initializeApplication(RunRecoveryService recovery, UserService users) { + return args -> { + recovery.recoverInterruptedRuns(); + users.initializeAdministrator(); + }; + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/model/ModelController.java similarity index 87% rename from server/src/main/java/tech/easyflow/manuagent/model/ModelController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/model/ModelController.java index 3ae5e04..0dc7055 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/model/ModelController.java @@ -1,8 +1,11 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.web.model; + +import tech.easyflow.manuagent.agent.model.ModelService; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.security.Principal; +import tech.easyflow.manuagent.admin.auth.UserService; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; @@ -24,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/models") public class ModelController { + private final UserService userService; + private final ModelService modelService; /** @@ -31,7 +36,8 @@ public class ModelController { * * @param modelService 模型服务 */ - public ModelController(ModelService modelService) { + public ModelController(UserService userService, ModelService modelService) { + this.userService = userService; this.modelService = modelService; } @@ -55,7 +61,7 @@ public class ModelController { @PostMapping @ResponseStatus(HttpStatus.CREATED) public ModelService.ModelView create(@Valid @RequestBody ModelService.ModelInput input, Principal principal) { - return modelService.save(null, input, principal); + return modelService.save(null, input, userService.requireUserId(principal.getName())); } /** @@ -71,7 +77,7 @@ public class ModelController { @PathVariable UUID id, @Valid @RequestBody ModelService.ModelInput input, Principal principal) { - return modelService.save(id, input, principal); + return modelService.save(id, input, userService.requireUserId(principal.getName())); } /** @@ -112,7 +118,7 @@ public class ModelController { @PostMapping("/{id}/default") @ResponseStatus(HttpStatus.NO_CONTENT) public void setDefault(@PathVariable UUID id, Principal principal) { - modelService.setDefault(id, principal); + modelService.setDefault(id, userService.requireUserId(principal.getName())); } /** diff --git a/server/src/main/java/tech/easyflow/manuagent/project/ProjectController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/project/ProjectController.java similarity index 91% rename from server/src/main/java/tech/easyflow/manuagent/project/ProjectController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/project/ProjectController.java index 09ac81b..1f9fc64 100644 --- a/server/src/main/java/tech/easyflow/manuagent/project/ProjectController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/project/ProjectController.java @@ -1,12 +1,16 @@ -package tech.easyflow.manuagent.project; +package tech.easyflow.manuagent.web.project; -import tech.easyflow.manuagent.agent.AgentRunService; -import com.fasterxml.jackson.databind.JsonNode; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; + +import tech.easyflow.manuagent.agent.runtime.AgentRunService; +import tools.jackson.databind.JsonNode; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.Principal; +import tech.easyflow.manuagent.admin.auth.UserService; import java.util.List; import java.util.UUID; import org.springframework.http.HttpHeaders; @@ -31,6 +35,8 @@ import org.springframework.web.multipart.MultipartFile; @RequestMapping("/api/projects") public class ProjectController { + private final UserService userService; + private final ProjectService projectService; private final ProjectFileService fileService; private final AgentRunService runService; @@ -42,10 +48,11 @@ public class ProjectController { * @param fileService 文件服务 * @param runService Agent Run 服务 */ - public ProjectController( + public ProjectController(UserService userService, ProjectService projectService, ProjectFileService fileService, AgentRunService runService) { + this.userService = userService; this.projectService = projectService; this.fileService = fileService; this.runService = runService; @@ -71,7 +78,7 @@ public class ProjectController { @PostMapping public ProjectService.ProjectView create(@Valid @RequestBody CreateProjectRequest request, Principal principal) { ProjectService.ProjectView project = projectService.create( - request.companyName(), request.applicationLevel(), principal); + request.companyName(), request.applicationLevel(), userService.requireUserId(principal.getName())); fileService.ensureWorkspace(project.id()); return project; } @@ -124,7 +131,7 @@ public class ProjectController { @PathVariable UUID projectId, @Valid @RequestBody ConfirmPlanRequest request, Principal principal) { - return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), principal); + return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), userService.requireUserId(principal.getName())); } /** @@ -142,7 +149,7 @@ public class ProjectController { @RequestParam MultipartFile file, @RequestParam(required = false) String relativePath, Principal principal) { - return fileService.upload(projectId, file, relativePath, principal); + return fileService.upload(projectId, file, relativePath, userService.requireUserId(principal.getName())); } /** diff --git a/server/src/main/java/tech/easyflow/manuagent/skill/SkillController.java b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/skill/SkillController.java similarity index 87% rename from server/src/main/java/tech/easyflow/manuagent/skill/SkillController.java rename to server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/skill/SkillController.java index 0f97a34..bf4be65 100644 --- a/server/src/main/java/tech/easyflow/manuagent/skill/SkillController.java +++ b/server/manuagent-web/src/main/java/tech/easyflow/manuagent/web/skill/SkillController.java @@ -1,6 +1,9 @@ -package tech.easyflow.manuagent.skill; +package tech.easyflow.manuagent.web.skill; + +import tech.easyflow.manuagent.agent.skill.SkillService; import java.security.Principal; +import tech.easyflow.manuagent.admin.auth.UserService; import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -21,6 +24,8 @@ import org.springframework.web.multipart.MultipartFile; @RequestMapping("/api/skills") public class SkillController { + private final UserService userService; + private final SkillService skillService; /** @@ -28,7 +33,8 @@ public class SkillController { * * @param skillService Skill 服务 */ - public SkillController(SkillService skillService) { + public SkillController(UserService userService, SkillService skillService) { + this.userService = userService; this.skillService = skillService; } @@ -75,7 +81,7 @@ public class SkillController { @PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public SkillService.SkillView importZip(@RequestParam MultipartFile file, Principal principal) { - return skillService.importZip(file, principal); + return skillService.importZip(file, userService.requireUserId(principal.getName())); } /** diff --git a/server/src/main/resources/application.yml b/server/manuagent-web/src/main/resources/application.yml similarity index 86% rename from server/src/main/resources/application.yml rename to server/manuagent-web/src/main/resources/application.yml index 7d54aad..1a32cfb 100644 --- a/server/src/main/resources/application.yml +++ b/server/manuagent-web/src/main/resources/application.yml @@ -24,8 +24,7 @@ spring: mybatis-flex: mapper-locations: - classpath*:/mapper/**/*.xml - type-aliases-package: tech.easyflow.manuagent.entity - type-handlers-package: tech.easyflow.manuagent.typehandler + type-handlers-package: tech.easyflow.manuagent.common.typehandler configuration: map-underscore-to-camel-case: true cache-enabled: false @@ -46,9 +45,10 @@ app: master-key: smart-factory-local-master-key admin-username: admin admin-password: admin123 - sandbox-image: smart-factory-agent-runtime:0.1.0 + sandbox-image: agent-sandbox:0.1 sandbox-network: bridge run-timeout: 60m + max-concurrent-runs: 2 logging: pattern: diff --git a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java similarity index 91% rename from server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java index abdc05c..680614a 100644 --- a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java @@ -5,34 +5,33 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import tech.easyflow.manuagent.agent.AgentEventService; -import tech.easyflow.manuagent.agent.AgentRunService; -import tech.easyflow.manuagent.agent.AgentRunStore; -import tech.easyflow.manuagent.artifact.ArtifactService; -import tech.easyflow.manuagent.artifact.DocxValidator; -import tech.easyflow.manuagent.auth.UserService; -import tech.easyflow.manuagent.entity.AgentEventEntity; -import tech.easyflow.manuagent.entity.AppUserEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.AppUserMapper; -import tech.easyflow.manuagent.mapper.ArtifactMapper; -import tech.easyflow.manuagent.mapper.ProjectMapper; -import tech.easyflow.manuagent.mapper.ProjectPlanMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; -import tech.easyflow.manuagent.mapper.SkillConfigMapper; -import tech.easyflow.manuagent.model.KeyCipher; -import tech.easyflow.manuagent.model.ModelService; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.skill.SkillPackageReader; -import tech.easyflow.manuagent.skill.SkillService; +import tech.easyflow.manuagent.agent.runtime.AgentEventService; +import tech.easyflow.manuagent.agent.runtime.AgentRunService; +import tech.easyflow.manuagent.agent.runtime.AgentRunStore; +import tech.easyflow.manuagent.agent.artifact.ArtifactService; +import tech.easyflow.manuagent.agent.artifact.DocxValidator; +import tech.easyflow.manuagent.agent.runtime.AgentEventEntity; +import tech.easyflow.manuagent.admin.auth.AppUserEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.admin.auth.AppUserMapper; +import tech.easyflow.manuagent.agent.artifact.ArtifactMapper; +import tech.easyflow.manuagent.agent.project.ProjectMapper; +import tech.easyflow.manuagent.agent.project.ProjectPlanMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; +import tech.easyflow.manuagent.agent.skill.SkillConfigMapper; +import tech.easyflow.manuagent.agent.model.KeyCipher; +import tech.easyflow.manuagent.agent.model.ModelService; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.skill.SkillPackageReader; +import tech.easyflow.manuagent.agent.skill.SkillService; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; -import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; -import tech.easyflow.manuagent.typehandler.UuidTypeHandler; -import tech.easyflow.manuagent.project.ProjectService; -import tech.easyflow.manuagent.project.ProjectFileService; -import com.fasterxml.jackson.databind.ObjectMapper; +import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tools.jackson.databind.ObjectMapper; import com.mybatisflex.core.MybatisFlexBootstrap; import com.mybatisflex.core.datasource.FlexDataSource; import com.mybatisflex.core.mybatis.FlexConfiguration; @@ -133,7 +132,7 @@ class DatabaseAndEventIntegrationTest { """).param("id", runId).param("projectId", projectId).param("modelId", modelId) .param("trace", UUID.randomUUID().toString()).update(); - AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper()); + AgentEventService service = new AgentEventService(agentEventMapper(), tools.jackson.databind.json.JsonMapper.builder().build()); long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id(); long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id(); long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id(); @@ -187,7 +186,7 @@ class DatabaseAndEventIntegrationTest { ProjectFileService files = mock(ProjectFileService.class); when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document); ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator()); - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = tools.jackson.databind.json.JsonMapper.builder().build(); ArtifactService.ArtifactView first = artifacts.publish( projectId, firstRunId, "DOCX", "draft.docx", "artifacts/draft.docx", @@ -248,7 +247,7 @@ class DatabaseAndEventIntegrationTest { .param("sha", "0".repeat(64)).update(); ProjectService service = new ProjectService( - projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper()); + projectMapper(), mock(ProjectPlanMapper.class), tools.jackson.databind.json.JsonMapper.builder().build()); service.delete(projectId); for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) { @@ -292,7 +291,7 @@ class DatabaseAndEventIntegrationTest { AgentRunMapper mapper = agentRunMapper(); AgentEventMapper events = agentEventMapper(); - AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), new ObjectMapper()); + AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), tools.jackson.databind.json.JsonMapper.builder().build()); AgentRunService.RunView initial = store.create(projectId, "INITIAL", null); assertThat(initial.status()).isEqualTo("RUNNING"); assertThat(store.latest(projectId).id()).isEqualTo(initial.id()); @@ -300,9 +299,9 @@ class DatabaseAndEventIntegrationTest { String interrupt = "{\"kind\":\"material_check\",\"items\":[]}"; assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1); - assertThat(new ObjectMapper().readTree( + assertThat(tools.jackson.databind.json.JsonMapper.builder().build().readTree( store.requireWaiting(projectId, "material_check").pendingInterrupt())) - .isEqualTo(new ObjectMapper().readTree(interrupt)); + .isEqualTo(tools.jackson.databind.json.JsonMapper.builder().build().readTree(interrupt)); store.completeWaiting(initial.id()); assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED"); @@ -322,8 +321,8 @@ class DatabaseAndEventIntegrationTest { response.setPayloadJson("{\"decisions\":[]}"); events.insertReturning(response); assertThat(events.selectLatestStartedPhase(resumed.id())).isEqualTo("PLANNING"); - assertThat(new ObjectMapper().readTree(events.selectLatestMaterialResponseJson(projectId))) - .isEqualTo(new ObjectMapper().readTree("{\"decisions\":[]}")); + assertThat(tools.jackson.databind.json.JsonMapper.builder().build().readTree(events.selectLatestMaterialResponseJson(projectId))) + .isEqualTo(tools.jackson.databind.json.JsonMapper.builder().build().readTree("{\"decisions\":[]}")); assertThat(mapper.interruptRunning(resumed.id())).isEqualTo(1); assertThat(mapper.interruptRunning(resumed.id())).isZero(); @@ -351,11 +350,9 @@ class DatabaseAndEventIntegrationTest { VALUES (:id, '规划企业', '规划项目', :thread, 'ADVANCED', :userId) """).param("id", projectId).param("thread", "plan-t-" + projectId).param("userId", userId).update(); - UserService users = mock(UserService.class); - when(users.requireUserId("admin")).thenReturn(userId); ProjectService service = new ProjectService( - projectMapper(), projectPlanMapper(), users, new ObjectMapper()); - ObjectMapper json = new ObjectMapper(); + projectMapper(), projectPlanMapper(), tools.jackson.databind.json.JsonMapper.builder().build()); + ObjectMapper json = tools.jackson.databind.json.JsonMapper.builder().build(); ProjectService.PlanView draft = service.saveDraftPlan( projectId, json.createObjectNode().put("title", "第一版"), userId); @@ -363,7 +360,7 @@ class DatabaseAndEventIntegrationTest { projectId, draft.id(), json.createObjectNode().put("title", "确认版"), - () -> "admin"); + userId); assertThat(draft.version()).isEqualTo(1); assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id()); @@ -383,14 +380,11 @@ class DatabaseAndEventIntegrationTest { UUID userId = UUID.randomUUID(); jdbc().sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')") .param("id", userId).param("name", "model-u-" + userId).update(); - UserService users = mock(UserService.class); - when(users.requireUserId("admin")).thenReturn(userId); - AppProperties properties = new AppProperties( + + AgentProperties properties = new AgentProperties( temporaryDirectory, temporaryDirectory.resolve("dashscope.key"), "integration-master-key", - "admin", - "admin", "runtime:test", "bridge", Duration.ofMinutes(1)); @@ -398,9 +392,8 @@ class DatabaseAndEventIntegrationTest { modelConfigMapper(), modelAssignmentMapper(), agentRunMapper(), - users, new KeyCipher(properties), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); Map capabilities = Map.of( "toolCalling", true, "reasoning", true, "contextWindow", 65_536); @@ -409,24 +402,24 @@ class DatabaseAndEventIntegrationTest { new ModelService.ModelInput( "测试模型", "https://model.example.test/", "model-v1", "secret-1234", Map.of("timeoutSeconds", 60), capabilities), - () -> "admin"); + userId); ModelService.ModelView updated = service.save( created.id(), new ModelService.ModelInput( "测试模型更新", "https://model.example.test", "model-v2", "", Map.of("timeoutSeconds", 120), capabilities), - () -> "admin"); + userId); ModelService.ModelView second = service.save( null, new ModelService.ModelInput( "第二测试模型", "https://second-model.example.test", "model-second", "secret-5678", Map.of("timeoutSeconds", 90), capabilities), - () -> "admin"); + userId); assertThat(created.defaultModel()).isTrue(); assertThat(second.defaultModel()).isFalse(); - service.setDefault(second.id(), () -> "admin"); + service.setDefault(second.id(), userId); ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id()); ModelService.ModelSecret defaultSecret = service.defaultModelSecret(); @@ -481,12 +474,10 @@ class DatabaseAndEventIntegrationTest { @Test void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception { SkillConfigMapper mapper = skillConfigMapper(); - AppProperties properties = new AppProperties( + AgentProperties properties = new AgentProperties( temporaryDirectory, temporaryDirectory.resolve("dashscope.key"), "integration-master-key", - "admin", - "admin", "runtime:test", "bridge", Duration.ofMinutes(1)); @@ -494,8 +485,7 @@ class DatabaseAndEventIntegrationTest { mapper, mock(PostgresSkillRepository.class), mock(SkillPackageReader.class), - mock(UserService.class), - properties); + properties); List skills = service.list(); assertThat(skills).isNotEmpty(); @@ -574,7 +564,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(ArtifactMapper.class) .start(); - String resource = "mapper/ArtifactMapper.xml"; + String resource = "mapper/agent/ArtifactMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -600,7 +590,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(AgentEventMapper.class) .start(); - String resource = "mapper/AgentEventMapper.xml"; + String resource = "mapper/agent/AgentEventMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -626,7 +616,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(AgentRunMapper.class) .start(); - String resource = "mapper/AgentRunMapper.xml"; + String resource = "mapper/agent/AgentRunMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -651,7 +641,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(ProjectMapper.class) .start(); - String resource = "mapper/ProjectMapper.xml"; + String resource = "mapper/agent/ProjectMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -677,7 +667,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(ProjectPlanMapper.class) .start(); - String resource = "mapper/ProjectPlanMapper.xml"; + String resource = "mapper/agent/ProjectPlanMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -703,7 +693,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(ModelConfigMapper.class) .start(); - String resource = "mapper/ModelConfigMapper.xml"; + String resource = "mapper/agent/ModelConfigMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -723,7 +713,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(ModelAssignmentMapper.class) .start(); - String resource = "mapper/ModelAssignmentMapper.xml"; + String resource = "mapper/agent/ModelAssignmentMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } @@ -743,7 +733,7 @@ class DatabaseAndEventIntegrationTest { .setDataSource(flexDataSource) .addMapper(SkillConfigMapper.class) .start(); - String resource = "mapper/SkillConfigMapper.xml"; + String resource = "mapper/agent/SkillConfigMapper.xml"; try (InputStream input = Resources.getResourceAsStream(resource)) { new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); } diff --git a/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java similarity index 70% rename from server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java index 49348a9..4baa90d 100644 --- a/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java @@ -2,8 +2,8 @@ package tech.easyflow.manuagent; import static org.assertj.core.api.Assertions.assertThat; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.model.KeyCipher; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.agent.model.KeyCipher; import java.nio.file.Path; import java.time.Duration; import org.junit.jupiter.api.Test; @@ -26,10 +26,9 @@ class KeyCipherAndShellTest { assertThat(cipher.decrypt(first)).isEqualTo("local-test-key"); } - private AppProperties properties() { - return new AppProperties( + private AgentProperties properties() { + return new AgentProperties( Path.of("data"), Path.of("dashscope"), - "unit-test-master", "admin", "admin123", - "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1)); + "unit-test-master", "agent-sandbox:test", "bridge", Duration.ofMinutes(1)); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java similarity index 75% rename from server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java index 055c0d7..acc3358 100644 --- a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java @@ -2,23 +2,23 @@ package tech.easyflow.manuagent; import static org.assertj.core.api.Assertions.assertThat; -import com.mybatisflex.spring.boot.MybatisFlexAutoConfiguration; +import com.mybatisflex.spring.boot.v4.MybatisFlexAutoConfiguration; import java.util.Map; import org.apache.ibatis.session.SqlSessionFactory; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import tech.easyflow.manuagent.config.MyBatisFlexConfiguration; -import tech.easyflow.manuagent.entity.AgentEventEntity; -import tech.easyflow.manuagent.entity.ArtifactEntity; -import tech.easyflow.manuagent.entity.ProjectPlanEntity; -import tech.easyflow.manuagent.mapper.AgentEventMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.mapper.ArtifactMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; -import tech.easyflow.manuagent.mapper.ProjectPlanMapper; -import tech.easyflow.manuagent.mapper.SkillConfigMapper; +import tech.easyflow.manuagent.web.config.MyBatisFlexConfiguration; +import tech.easyflow.manuagent.agent.runtime.AgentEventEntity; +import tech.easyflow.manuagent.agent.artifact.ArtifactEntity; +import tech.easyflow.manuagent.agent.project.ProjectPlanEntity; +import tech.easyflow.manuagent.agent.runtime.AgentEventMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.artifact.ArtifactMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; +import tech.easyflow.manuagent.agent.project.ProjectPlanMapper; +import tech.easyflow.manuagent.agent.skill.SkillConfigMapper; /** * 验证生产环境使用的 MyBatis-Flex 自动配置、Mapper 扫描和 XML 资源能够共同启动。 @@ -47,8 +47,8 @@ class MyBatisFlexContextTest { "spring.datasource.password=test", "spring.datasource.hikari.initialization-fail-timeout=-1", "mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml", - "mybatis-flex.type-aliases-package=tech.easyflow.manuagent.entity", - "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.typehandler", + "mybatis-flex.type-aliases-package=tech.easyflow.manuagent.agent", + "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.common.typehandler", "mybatis-flex.configuration.map-underscore-to-camel-case=true", "mybatis-flex.configuration.cache-enabled=false", "mybatis-flex.configuration.local-cache-scope=statement") @@ -63,27 +63,27 @@ class MyBatisFlexContextTest { var configuration = context.getBean(SqlSessionFactory.class).getConfiguration(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning")).isTrue(); + "tech.easyflow.manuagent.agent.runtime.AgentEventMapper.insertReturning")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.AgentRunMapper.interruptRunningAfterRestart")).isTrue(); + "tech.easyflow.manuagent.agent.runtime.AgentRunMapper.interruptRunningAfterRestart")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.ModelConfigMapper.insertModel")).isTrue(); + "tech.easyflow.manuagent.agent.model.ModelConfigMapper.insertModel")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.ModelConfigMapper.updateModel")).isTrue(); + "tech.easyflow.manuagent.agent.model.ModelConfigMapper.updateModel")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.ModelConfigMapper.clearDefault")).isTrue(); + "tech.easyflow.manuagent.agent.model.ModelConfigMapper.clearDefault")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.ModelConfigMapper.setDefault")).isTrue(); + "tech.easyflow.manuagent.agent.model.ModelConfigMapper.setDefault")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")).isTrue(); + "tech.easyflow.manuagent.agent.project.ProjectPlanMapper.confirmDraft")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.SkillConfigMapper.selectViews")).isTrue(); + "tech.easyflow.manuagent.agent.skill.SkillConfigMapper.selectViews")).isTrue(); assertThat(configuration.hasStatement( - "tech.easyflow.manuagent.mapper.SkillConfigMapper.updateEnabled")).isTrue(); + "tech.easyflow.manuagent.agent.skill.SkillConfigMapper.updateEnabled")).isTrue(); // 自定义 INSERT/UPDATE ... RETURNING 也应沿用迁移前视图字段,避免回传内部列。 String eventReturning = returningClause(configuration - .getMappedStatement("tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning") + .getMappedStatement("tech.easyflow.manuagent.agent.runtime.AgentEventMapper.insertReturning") .getBoundSql(Map.of("event", new AgentEventEntity())) .getSql()); assertThat(eventReturning) @@ -91,7 +91,7 @@ class MyBatisFlexContextTest { .doesNotContain("event_id"); String artifactReturning = returningClause(configuration - .getMappedStatement("tech.easyflow.manuagent.mapper.ArtifactMapper.upsert") + .getMappedStatement("tech.easyflow.manuagent.agent.artifact.ArtifactMapper.upsert") .getBoundSql(Map.of("artifact", new ArtifactEntity())) .getSql()); assertThat(artifactReturning) @@ -99,19 +99,19 @@ class MyBatisFlexContextTest { .doesNotContain("relative_path", "mime_type", "sha256", "created_at"); String draftReturning = returningClause(configuration - .getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.insertNextDraft") + .getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.insertNextDraft") .getBoundSql(Map.of("plan", new ProjectPlanEntity())) .getSql()); assertPlanViewProjection(draftReturning); String confirmReturning = returningClause(configuration - .getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft") + .getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.confirmDraft") .getBoundSql(Map.of()) .getSql()); assertPlanViewProjection(confirmReturning); String currentPlanSql = configuration - .getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.selectCurrent") + .getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.selectCurrent") .getBoundSql(Map.of()) .getSql() .toLowerCase(java.util.Locale.ROOT); diff --git a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java similarity index 82% rename from server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java index c4afeb9..d8f3a3b 100644 --- a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java @@ -5,9 +5,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.mybatisflex.spring.boot.FlexTransactionAutoConfiguration; -import com.mybatisflex.spring.boot.MybatisFlexAutoConfiguration; +import tools.jackson.databind.ObjectMapper; +import com.mybatisflex.spring.boot.v4.FlexTransactionAutoConfiguration; +import com.mybatisflex.spring.boot.v4.MybatisFlexAutoConfiguration; import java.util.UUID; import javax.sql.DataSource; import org.flywaydb.core.Flyway; @@ -15,9 +15,9 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.postgresql.util.PSQLException; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; -import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,15 +27,14 @@ import org.springframework.transaction.PlatformTransactionManager; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import tech.easyflow.manuagent.auth.UserService; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.config.MyBatisFlexConfiguration; -import tech.easyflow.manuagent.mapper.AppUserMapper; -import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; -import tech.easyflow.manuagent.mapper.ModelConfigMapper; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.model.KeyCipher; -import tech.easyflow.manuagent.model.ModelService; +import tech.easyflow.manuagent.agent.config.AgentProperties; +import tech.easyflow.manuagent.web.config.MyBatisFlexConfiguration; +import tech.easyflow.manuagent.admin.auth.AppUserMapper; +import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper; +import tech.easyflow.manuagent.agent.model.ModelConfigMapper; +import tech.easyflow.manuagent.agent.runtime.AgentRunMapper; +import tech.easyflow.manuagent.agent.model.KeyCipher; +import tech.easyflow.manuagent.agent.model.ModelService; /** * 使用真实 Spring 事务代理、MyBatis-Flex Mapper 和 PostgreSQL 验证跨语句回滚。 @@ -83,8 +82,8 @@ class MyBatisFlexTransactionIntegrationTest { "spring.datasource.username=" + POSTGRES.getUsername(), "spring.datasource.password=" + POSTGRES.getPassword(), "mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml", - "mybatis-flex.type-aliases-package=tech.easyflow.manuagent.entity", - "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.typehandler", + "mybatis-flex.type-aliases-package=tech.easyflow.manuagent.agent", + "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.common.typehandler", "mybatis-flex.configuration.map-underscore-to-camel-case=true") .run(context -> { assertThat(context.getStartupFailure()).isNull(); @@ -114,13 +113,12 @@ class MyBatisFlexTransactionIntegrationTest { .param("targetId", enabledTargetId) .param("targetName", "target-" + enabledTargetId) .update(); - UserService users = context.getBean(UserService.class); - when(users.requireUserId("admin")).thenReturn(missingUserId); + ModelService modelService = context.getBean(ModelService.class); assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1); assertThat(AopUtils.isAopProxy(modelService)).isTrue(); - assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, () -> "admin")) + assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, missingUserId)) .hasRootCauseInstanceOf(PSQLException.class); assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id") @@ -140,11 +138,6 @@ class MyBatisFlexTransactionIntegrationTest { @Configuration(proxyBeanMethods = false) static class TransactionTestConfiguration { - /** 提供可按测试场景设置返回值的用户服务。 */ - @Bean - UserService userService() { - return mock(UserService.class); - } /** * 装配真实 Mapper 驱动的模型服务;未参与本场景的密钥与应用配置依赖使用边界 Mock。 @@ -153,15 +146,13 @@ class MyBatisFlexTransactionIntegrationTest { ModelService modelService( ModelConfigMapper modelMapper, ModelAssignmentMapper assignmentMapper, - AgentRunMapper runMapper, - UserService userService) { + AgentRunMapper runMapper) { return new ModelService( modelMapper, assignmentMapper, runMapper, - userService, mock(KeyCipher.class), - new ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); } } } diff --git a/server/src/test/java/tech/easyflow/manuagent/auth/AuthControllerTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/auth/AuthControllerTest.java similarity index 94% rename from server/src/test/java/tech/easyflow/manuagent/auth/AuthControllerTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/auth/AuthControllerTest.java index f29af05..06e6ef3 100644 --- a/server/src/test/java/tech/easyflow/manuagent/auth/AuthControllerTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/auth/AuthControllerTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.auth; +package tech.easyflow.manuagent.web.auth; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -7,7 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import tech.easyflow.manuagent.common.GlobalExceptionHandler; +import tech.easyflow.manuagent.web.common.GlobalExceptionHandler; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; diff --git a/server/src/test/java/tech/easyflow/manuagent/common/GlobalExceptionHandlerTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandlerTest.java similarity index 94% rename from server/src/test/java/tech/easyflow/manuagent/common/GlobalExceptionHandlerTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandlerTest.java index 65feef6..cf9907e 100644 --- a/server/src/test/java/tech/easyflow/manuagent/common/GlobalExceptionHandlerTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/common/GlobalExceptionHandlerTest.java @@ -1,4 +1,4 @@ -package tech.easyflow.manuagent.common; +package tech.easyflow.manuagent.web.common; import static org.assertj.core.api.Assertions.assertThatCode; diff --git a/server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/config/AppPropertiesValidationTest.java similarity index 89% rename from server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/config/AppPropertiesValidationTest.java index e62e7f0..55a00c8 100644 --- a/server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/config/AppPropertiesValidationTest.java @@ -1,4 +1,6 @@ -package tech.easyflow.manuagent.config; +package tech.easyflow.manuagent.web.config; + +import tech.easyflow.manuagent.agent.config.AgentProperties; import static org.assertj.core.api.Assertions.assertThat; @@ -25,7 +27,7 @@ class AppPropertiesValidationTest { "app.master-key=unit-test-master-key", "app.admin-username=admin", "app.admin-password=admin123", - "app.sandbox-image=runtime:test", + "app.sandbox-image=agent-sandbox:test", "app.sandbox-network=bridge", "app.run-timeout=1m"); @@ -57,7 +59,7 @@ class AppPropertiesValidationTest { void shouldBindConfiguredMasterKey() { contextRunner.run(context -> { assertThat(context).hasNotFailed(); - assertThat(context.getBean(AppProperties.class).masterKey()) + assertThat(context.getBean(AgentProperties.class).masterKey()) .isEqualTo("unit-test-master-key"); }); } @@ -66,7 +68,7 @@ class AppPropertiesValidationTest { * 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。 */ @Configuration(proxyBeanMethods = false) - @EnableConfigurationProperties(AppProperties.class) + @EnableConfigurationProperties(AgentProperties.class) static class PropertiesConfiguration { } } diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/model/ModelControllerTest.java similarity index 92% rename from server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java rename to server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/model/ModelControllerTest.java index 7a4676d..3f2eee7 100644 --- a/server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/model/ModelControllerTest.java @@ -1,4 +1,6 @@ -package tech.easyflow.manuagent.model; +package tech.easyflow.manuagent.web.model; + +import tech.easyflow.manuagent.agent.model.ModelService; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -32,7 +34,7 @@ class ModelControllerTest { ModelService service = mock(ModelService.class); when(service.test(any(ModelService.ConnectionTestInput.class))) .thenReturn(new ModelService.ConnectionResult(true, 12L, "连接正常")); - MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build(); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(mock(tech.easyflow.manuagent.admin.auth.UserService.class), service)).build(); mvc.perform(post("/api/models/test") .contentType(MediaType.APPLICATION_JSON) @@ -64,7 +66,7 @@ class ModelControllerTest { void shouldRouteDisableRequestToModelService() throws Exception { UUID id = UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"); ModelService service = mock(ModelService.class); - MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build(); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(mock(tech.easyflow.manuagent.admin.auth.UserService.class), service)).build(); mvc.perform(patch("/api/models/{id}/enabled", id) .contentType(MediaType.APPLICATION_JSON) diff --git a/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/project/ProjectControllerTest.java b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/project/ProjectControllerTest.java new file mode 100644 index 0000000..48084ca --- /dev/null +++ b/server/manuagent-web/src/test/java/tech/easyflow/manuagent/web/project/ProjectControllerTest.java @@ -0,0 +1,46 @@ +package tech.easyflow.manuagent.web.project; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tech.easyflow.manuagent.admin.auth.UserService; +import tech.easyflow.manuagent.agent.project.ProjectFileService; +import tech.easyflow.manuagent.agent.project.ProjectService; +import tech.easyflow.manuagent.agent.runtime.AgentRunService; + +class ProjectControllerTest { + @Test + void shouldResolveActorFromPrincipalInsteadOfClientInput() throws Exception { + UUID actor = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + UserService users = mock(UserService.class); + ProjectService projects = mock(ProjectService.class); + ProjectFileService files = mock(ProjectFileService.class); + when(users.requireUserId("authenticated-user")).thenReturn(actor); + when(projects.create(eq("Test"), eq("ADVANCED"), any())).thenReturn( + new ProjectService.ProjectView(projectId, "Test", "Test", "thread", "ADVANCED", + "MATERIAL_CHECK", 0, null, null)); + var mvc = MockMvcBuilders.standaloneSetup( + new ProjectController(users, projects, files, mock(AgentRunService.class))).build(); + + mvc.perform(post("/api/projects").principal(() -> "authenticated-user") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"companyName":"Test","applicationLevel":"ADVANCED", + "userId":"00000000-0000-0000-0000-000000000001"} + """)) + .andExpect(status().isOk()); + + verify(projects).create("Test", "ADVANCED", actor); + verify(files).ensureWorkspace(projectId); + } +} diff --git a/server/pom.xml b/server/pom.xml index 4b2a10c..f68db2c 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -1,116 +1,89 @@ - 4.0.0 - org.springframework.boot spring-boot-starter-parent - 3.5.16 + 4.1.1 - tech.easyflow - ManuAgent-server + manuagent-parent 0.1.0 + pom ManuAgent - + + manuagent-common + manuagent-admin + manuagent-agent + manuagent-web + - 21 + 25 2.0.1 1.11.8 + 3.5.19 3.2.3 1.21.4 - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-jdbc - - - com.mybatis-flex - mybatis-flex-spring-boot3-starter - ${mybatis-flex.version} - - - org.flywaydb - flyway-database-postgresql - - - org.postgresql - postgresql - runtime - - - org.apache.tika - tika-core - ${tika.version} - - - io.agentscope - agentscope-harness - ${agentscope.version} - - - io.agentscope - agentscope-extensions-model-openai - ${agentscope.version} - - - io.agentscope - agentscope-extensions-agui - ${agentscope.version} - - - io.agentscope - agentscope-extensions-skill-postgresql-repository - ${agentscope.version} - - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - org.testcontainers - postgresql - ${testcontainers.version} - test - - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - + + + + tech.easyflow + manuagent-common + ${project.version} + + + tech.easyflow + manuagent-admin + ${project.version} + + + tech.easyflow + manuagent-agent + ${project.version} + + + org.mybatis + mybatis + ${mybatis.version} + + + com.mybatis-flex + mybatis-flex-core + ${mybatis-flex.version} + + + com.mybatis-flex + mybatis-flex-spring-boot4-starter + ${mybatis-flex.version} + + + org.apache.tika + tika-core + ${tika.version} + + + io.agentscope + agentscope-harness + ${agentscope.version} + + + io.agentscope + agentscope-extensions-model-openai + ${agentscope.version} + + + io.agentscope + agentscope-extensions-agui + ${agentscope.version} + + + io.agentscope + agentscope-extensions-skill-postgresql-repository + ${agentscope.version} + + + diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java deleted file mode 100644 index d53448c..0000000 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package tech.easyflow.manuagent.agent; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.agentscope.core.agui.adapter.AguiAgentAdapter; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import tech.easyflow.manuagent.project.ProjectFileService; -import tech.easyflow.manuagent.project.ProjectService; -import tech.easyflow.manuagent.skill.SkillService; - -/** - * 验证 Agent 事件持久化的精简规则。 - */ -class AgentExecutionServiceTest { - - /** - * Run 在创建时已经固化模型配置 ID;执行和模型重连都必须沿用该 ID, - * 不能在工厂内部重新读取可能已经变化的全局默认模型。 - */ - @Test - void shouldCreateAgentWithModelBoundToRun() { - UUID projectId = UUID.randomUUID(); - UUID runId = UUID.randomUUID(); - UUID modelId = UUID.randomUUID(); - AgentFactory factory = mock(AgentFactory.class); - AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class); - AguiAgentAdapter adapter = mock(AguiAgentAdapter.class); - SkillService skillService = mock(SkillService.class); - when(handle.adapter()).thenReturn(adapter); - when(adapter.run(any())).thenReturn(Flux.empty()); - when(skillService.enabledNames()).thenReturn(new String[] {"document"}); - when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle); - - AgentExecutionService service = new AgentExecutionService( - new ObjectMapper(), - factory, - mock(AgentEventService.class), - mock(ProjectFileService.class), - skillService); - ProjectService.ProjectView project = mock(ProjectService.ProjectView.class); - when(project.id()).thenReturn(projectId); - when(project.threadId()).thenReturn("project-" + projectId); - AgentRunService.RunView run = new AgentRunService.RunView( - runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null); - - service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false); - - verify(factory).create(eq(projectId), eq(modelId), any(String[].class)); - } - - /** - * 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。 - */ - @Test - void shouldStripInlineImageDataFromPersistedToolResult() { - String content = """ - document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]} - {"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}} - """; - - String result = AgentExecutionService.stripInlineImageData(content); - - assertThat(result).contains("render-1.png"); - assertThat(result).doesNotContain("very-large-base64"); - } -} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java deleted file mode 100644 index 55f6c9a..0000000 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package tech.easyflow.manuagent.agent; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import io.agentscope.core.model.transport.HttpTransportException; -import io.agentscope.core.skill.AgentSkill; -import java.nio.file.Path; -import java.time.OffsetDateTime; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import org.junit.jupiter.api.Test; -import org.springframework.transaction.support.TransactionSynchronizationManager; -import org.springframework.transaction.support.TransactionTemplate; -import tech.easyflow.manuagent.artifact.ArtifactService; -import tech.easyflow.manuagent.auth.UserService; -import tech.easyflow.manuagent.mapper.AgentRunMapper; -import tech.easyflow.manuagent.project.ProjectFileService; -import tech.easyflow.manuagent.project.ProjectService; - -/** - * 验证 Agent Run 的模型重连边界。 - */ -class AgentRunServiceTest { - - /** - * 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run, - * 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。 - */ - @Test - void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() { - UUID projectId = UUID.randomUUID(); - UUID modelId = UUID.randomUUID(); - UUID currentRunId = UUID.randomUUID(); - UUID replacementRunId = UUID.randomUUID(); - OffsetDateTime now = OffsetDateTime.now(); - ProjectService.ProjectView project = new ProjectService.ProjectView( - projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now); - AgentRunService.RunView current = new AgentRunService.RunView( - currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null); - AgentRunService.RunView replacement = new AgentRunService.RunView( - replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null); - - AgentRunMapper runMapper = mock(AgentRunMapper.class); - AgentRunStore runStore = mock(AgentRunStore.class); - ProjectService projectService = mock(ProjectService.class); - UserService userService = mock(UserService.class); - when(projectService.require(projectId)).thenReturn(project); - when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID()); - when(runStore.latest(projectId)).thenReturn(current); - when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK"); - when(runMapper.interruptRunning(currentRunId)).thenReturn(1); - when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement); - - AgentRunService service = new AgentRunService( - runMapper, - new ObjectMapper(), - mock(AgentExecutionService.class), - mock(AgentOutputService.class), - runStore, - mock(AgentEventService.class), - projectService, - mock(ProjectFileService.class), - userService, - mock(ArtifactService.class), - mock(ExecutorService.class), - mock(TransactionTemplate.class)); - - // 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。 - TransactionSynchronizationManager.initSynchronization(); - try { - assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement); - verify(runMapper).interruptRunning(currentRunId); - verify(runStore).create(projectId, "RESUME", currentRunId, modelId); - } finally { - TransactionSynchronizationManager.clearSynchronization(); - } - } - - /** - * 网络故障和服务端错误允许重连,参数错误保持原始失败。 - */ - @Test - void shouldRetryOnlyRecoverableModelFailures() { - assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5); - assertThat(AgentExecutionService.isRetryableModelFailure( - new RuntimeException(new HttpTransportException("disconnected")))).isTrue(); - assertThat(AgentExecutionService.isRetryableModelFailure( - new HttpTransportException("unavailable", 503, ""))).isTrue(); - assertThat(AgentExecutionService.isRetryableModelFailure( - new HttpTransportException("invalid request", 400, ""))).isFalse(); - assertThat(AgentExecutionService.isRetryableModelFailure( - new IllegalArgumentException("invalid prompt"))).isFalse(); - } - - /** - * 验证上下文压缩只按模型窗口 90% Token 触发。 - */ - @Test - void shouldCompactAtNinetyPercentTokensOnly() { - var config = AgentFactory.compactionFor(100_000, "summary"); - - assertThat(config.getTriggerTokens()).isEqualTo(90_000); - assertThat(config.getTriggerMessages()).isZero(); - assertThat(config.getSummaryPrompt()).isEqualTo("summary"); - } - - /** - * 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。 - */ - @Test - void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() { - AgentSkill source = new AgentSkill( - Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"), - "使用说明", - Map.of("references/guide.md", "参考内容"), - "imported", - Path.of("/skills/pdf")); - - AgentSkill canonical = AgentFactory.canonicalSkill(source); - - assertThat(canonical.getSkillId()).isEqualTo("pdf"); - assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata()); - assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent()); - assertThat(canonical.getResources()).isEqualTo(source.getResources()); - assertThat(canonical.getSource()).isEqualTo("imported"); - assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir()); - } - - /** - * 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。 - */ - @Test - void shouldNormalizePlanningYearArray() { - ObjectNode plan = new ObjectMapper().createObjectNode(); - plan.putArray("planningYears").add("2026").add("2027"); - - AgentOutputService.normalizePlanningYears(plan); - - assertThat(plan.path("planningYears").asInt()).isEqualTo(2); - assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027"); - } -} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/DocumentViewToolTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/DocumentViewToolTest.java deleted file mode 100644 index 4143d01..0000000 --- a/server/src/test/java/tech/easyflow/manuagent/agent/DocumentViewToolTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package tech.easyflow.manuagent.agent; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.agentscope.core.model.ToolSchema; -import io.agentscope.core.tool.Toolkit; -import org.junit.jupiter.api.Test; - -/** - * 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。 - */ -class DocumentViewToolTest { - - /** - * 验证多视图参数能够进入模型工具定义。 - */ - @Test - void shouldRegisterMultiViewSchema() { - Toolkit toolkit = new Toolkit(); - - toolkit.registerTool(new DocumentViewTool(new ObjectMapper())); - - ToolSchema schema = toolkit.getToolSchemas().getFirst(); - assertThat(schema.getName()).isEqualTo("document_view"); - assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range"); - } -} diff --git a/web-ui/.dockerignore b/web-ui/.dockerignore index bbaa109..be5a421 100644 --- a/web-ui/.dockerignore +++ b/web-ui/.dockerignore @@ -1,5 +1,5 @@ -node_modules -dist +**/node_modules +**/dist client *.tsbuildinfo npm-debug.log diff --git a/web-ui/Dockerfile b/web-ui/Dockerfile index ecf778f..6fa513d 100644 --- a/web-ui/Dockerfile +++ b/web-ui/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /workspace # 先复制依赖清单以复用 Docker 构建缓存;只有依赖变化时才重新执行 npm ci。 COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/common/package.json packages/common/package.json +COPY packages/admin/package.json packages/admin/package.json +COPY packages/agent/package.json packages/agent/package.json RUN npm ci COPY . . @@ -16,6 +20,6 @@ RUN npm run build FROM nginx:1.29.8-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /workspace/dist /usr/share/nginx/html +COPY --from=builder /workspace/apps/web/dist /usr/share/nginx/html EXPOSE 80 diff --git a/web-ui/index.html b/web-ui/apps/web/index.html similarity index 100% rename from web-ui/index.html rename to web-ui/apps/web/index.html diff --git a/web-ui/apps/web/package.json b/web-ui/apps/web/package.json new file mode 100644 index 0000000..f045d59 --- /dev/null +++ b/web-ui/apps/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "@manuagent/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "dev": "vite --host 127.0.0.1", + "build": "vite build" + }, + "dependencies": { + "vue": "3.5.41", + "vue-router": "^4.6.3", + "element-plus": "2.14.5", + "@element-plus/icons-vue": "^2.3.2", + "markstream-vue": "2.0.6", + "@manuagent/common": "0.1.0", + "@manuagent/admin": "0.1.0", + "@manuagent/agent": "0.1.0" + } +} diff --git a/web-ui/public/favicon.svg b/web-ui/apps/web/public/favicon.svg similarity index 100% rename from web-ui/public/favicon.svg rename to web-ui/apps/web/public/favicon.svg diff --git a/web-ui/apps/web/src/App.vue b/web-ui/apps/web/src/App.vue new file mode 100644 index 0000000..823e0a4 --- /dev/null +++ b/web-ui/apps/web/src/App.vue @@ -0,0 +1,35 @@ + + + diff --git a/web-ui/src/main.ts b/web-ui/apps/web/src/main.ts similarity index 82% rename from web-ui/src/main.ts rename to web-ui/apps/web/src/main.ts index 9df36fe..aa1b2a7 100644 --- a/web-ui/src/main.ts +++ b/web-ui/apps/web/src/main.ts @@ -18,10 +18,12 @@ import { } from 'element-plus' import 'element-plus/dist/index.css' import 'markstream-vue/index.css' -import './styles.css' +import '@manuagent/common/styles.css' +import { setUnauthorizedHandler } from '@manuagent/common' import App from './App.vue' import { router } from './router' +setUnauthorizedHandler(() => location.assign('/login')) const app = createApp(App) for (const component of [ ElButton, ElDialog, ElForm, ElFormItem, ElIcon, ElInput, diff --git a/web-ui/apps/web/src/router.ts b/web-ui/apps/web/src/router.ts new file mode 100644 index 0000000..62d2f4c --- /dev/null +++ b/web-ui/apps/web/src/router.ts @@ -0,0 +1,11 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { agentRoutes } from '@manuagent/agent' + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('@manuagent/admin').then(module => module.LoginPage), props: { destination: '/projects' }, meta: { public: true } }, + { path: '/', redirect: '/projects' }, + ...agentRoutes + ] +}) diff --git a/web-ui/apps/web/tsconfig.json b/web-ui/apps/web/tsconfig.json new file mode 100644 index 0000000..4a890a3 --- /dev/null +++ b/web-ui/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ] +} diff --git a/web-ui/tsconfig.node.json b/web-ui/apps/web/tsconfig.node.json similarity index 100% rename from web-ui/tsconfig.node.json rename to web-ui/apps/web/tsconfig.node.json diff --git a/web-ui/vite.config.ts b/web-ui/apps/web/vite.config.ts similarity index 89% rename from web-ui/vite.config.ts rename to web-ui/apps/web/vite.config.ts index 3b20681..da176b5 100644 --- a/web-ui/vite.config.ts +++ b/web-ui/apps/web/vite.config.ts @@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], + resolve: { dedupe: ['vue', 'vue-router'] }, server: { port: 15173, proxy: { diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 33b55e7..730a662 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -7,15 +7,10 @@ "": { "name": "smart-factory-approval-agent-client", "version": "0.1.0", - "dependencies": { - "@ag-ui/client": "0.0.58", - "@element-plus/icons-vue": "^2.3.2", - "@lucide/vue": "^1.35.0", - "element-plus": "2.14.5", - "markstream-vue": "2.0.6", - "vue": "3.5.41", - "vue-router": "^4.6.3" - }, + "workspaces": [ + "apps/*", + "packages/*" + ], "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", "@vue/test-utils": "^2.4.6", @@ -26,6 +21,20 @@ "vue-tsc": "^3.2.2" } }, + "apps/web": { + "name": "@manuagent/web", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@manuagent/admin": "0.1.0", + "@manuagent/agent": "0.1.0", + "@manuagent/common": "0.1.0", + "element-plus": "2.14.5", + "markstream-vue": "2.0.6", + "vue": "3.5.41", + "vue-router": "^4.6.3" + } + }, "node_modules/@ag-ui/client": { "version": "0.0.58", "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.58.tgz", @@ -779,6 +788,22 @@ "vue": ">=3.0.1" } }, + "node_modules/@manuagent/admin": { + "resolved": "packages/admin", + "link": true + }, + "node_modules/@manuagent/agent": { + "resolved": "packages/agent", + "link": true + }, + "node_modules/@manuagent/common": { + "resolved": "packages/common", + "link": true + }, + "node_modules/@manuagent/web": { + "resolved": "apps/web", + "link": true + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -3603,6 +3628,34 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/admin": { + "name": "@manuagent/admin", + "version": "0.1.0", + "dependencies": { + "@manuagent/common": "0.1.0", + "element-plus": "2.14.5", + "vue": "3.5.41", + "vue-router": "^4.6.3" + } + }, + "packages/agent": { + "name": "@manuagent/agent", + "version": "0.1.0", + "dependencies": { + "@ag-ui/client": "0.0.58", + "@element-plus/icons-vue": "^2.3.2", + "@lucide/vue": "^1.35.0", + "@manuagent/common": "0.1.0", + "element-plus": "2.14.5", + "markstream-vue": "2.0.6", + "vue": "3.5.41", + "vue-router": "^4.6.3" + } + }, + "packages/common": { + "name": "@manuagent/common", + "version": "0.1.0" } } } diff --git a/web-ui/package.json b/web-ui/package.json index b2ce66d..b544ab0 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -3,19 +3,15 @@ "private": true, "version": "0.1.0", "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "vue-tsc -b && vite build", - "test": "vitest run" - }, - "dependencies": { - "@ag-ui/client": "0.0.58", - "@element-plus/icons-vue": "^2.3.2", - "@lucide/vue": "^1.35.0", - "element-plus": "2.14.5", - "markstream-vue": "2.0.6", - "vue": "3.5.41", - "vue-router": "^4.6.3" + "dev": "npm run dev --workspace @manuagent/web", + "typecheck": "npm run typecheck --workspaces", + "test": "vitest run --config apps/web/vite.config.ts", + "build": "npm run typecheck && npm run build --workspace @manuagent/web" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", diff --git a/web-ui/packages/admin/package.json b/web-ui/packages/admin/package.json new file mode 100644 index 0000000..b796de5 --- /dev/null +++ b/web-ui/packages/admin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@manuagent/admin", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@manuagent/common": "0.1.0", + "vue": "3.5.41", + "vue-router": "^4.6.3", + "element-plus": "2.14.5" + }, + "scripts": { + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + } +} diff --git a/web-ui/packages/admin/src/api.ts b/web-ui/packages/admin/src/api.ts new file mode 100644 index 0000000..a70006c --- /dev/null +++ b/web-ui/packages/admin/src/api.ts @@ -0,0 +1,9 @@ +import { api, resetCsrf } from '@manuagent/common' + +export function login(username: string, password: string) { + resetCsrf() + return api('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }) +} diff --git a/web-ui/packages/admin/src/index.ts b/web-ui/packages/admin/src/index.ts new file mode 100644 index 0000000..109cd11 --- /dev/null +++ b/web-ui/packages/admin/src/index.ts @@ -0,0 +1,2 @@ +export { default as LoginPage } from './pages/LoginPage.vue' +export { login } from './api' diff --git a/web-ui/src/pages/LoginPage.vue b/web-ui/packages/admin/src/pages/LoginPage.vue similarity index 92% rename from web-ui/src/pages/LoginPage.vue rename to web-ui/packages/admin/src/pages/LoginPage.vue index 3434265..55e8c2f 100644 --- a/web-ui/src/pages/LoginPage.vue +++ b/web-ui/packages/admin/src/pages/LoginPage.vue @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { login } from '../api' +const props = defineProps<{ destination: string }>() const router = useRouter() const username = ref('admin') const password = ref('admin123') @@ -15,7 +16,7 @@ async function submit() { error.value = '' try { await login(username.value, password.value) - await router.replace('/projects') + await router.replace(props.destination) } catch (reason) { error.value = reason instanceof Error ? reason.message : '登录失败' } finally { diff --git a/web-ui/packages/admin/tsconfig.json b/web-ui/packages/admin/tsconfig.json new file mode 100644 index 0000000..4a890a3 --- /dev/null +++ b/web-ui/packages/admin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ] +} diff --git a/web-ui/packages/agent/package.json b/web-ui/packages/agent/package.json new file mode 100644 index 0000000..6fcfb5b --- /dev/null +++ b/web-ui/packages/agent/package.json @@ -0,0 +1,22 @@ +{ + "name": "@manuagent/agent", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@manuagent/common": "0.1.0", + "@ag-ui/client": "0.0.58", + "@element-plus/icons-vue": "^2.3.2", + "@lucide/vue": "^1.35.0", + "element-plus": "2.14.5", + "markstream-vue": "2.0.6", + "vue": "3.5.41", + "vue-router": "^4.6.3" + }, + "scripts": { + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + } +} diff --git a/web-ui/src/api.ts b/web-ui/packages/agent/src/api.ts similarity index 60% rename from web-ui/src/api.ts rename to web-ui/packages/agent/src/api.ts index 0a33cc7..db7c10a 100644 --- a/web-ui/src/api.ts +++ b/web-ui/packages/agent/src/api.ts @@ -1,3 +1,6 @@ +import { request } from '@manuagent/common' +export { api } from '@manuagent/common' + export interface Project { id: string companyName: string @@ -44,47 +47,6 @@ export interface Artifact { publishedAt: string } -let csrf: { token: string; headerName: string } | null = null - -async function ensureCsrf() { - if (!csrf) csrf = await raw('/api/auth/csrf') - return csrf! -} - -async function raw(path: string, options: RequestInit = {}) { - const response = await fetch(path, { credentials: 'include', ...options }) - if (response.status === 401 && path !== '/api/auth/login') { - location.assign('/login') - throw new Error('登录状态已失效') - } - if (!response.ok) { - const body = await response.json().catch(() => ({ message: `请求失败(${response.status})` })) - throw new Error(body.message || `请求失败(${response.status})`) - } - if (response.status === 204) return null - return response.json() -} - -export async function api(path: string, options: RequestInit = {}): Promise { - const method = (options.method || 'GET').toUpperCase() - const headers = new Headers(options.headers) - if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { - const token = await ensureCsrf() - headers.set(token.headerName, token.token) - } - if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json') - return raw(path, { ...options, headers }) -} - -export async function login(username: string, password: string) { - csrf = null - return raw('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) - }) -} - export function streamEvents( projectId: string, after: number, @@ -98,13 +60,11 @@ export function streamEvents( const connect = async () => { while (!stopped) { try { - const response = await fetch(`/api/projects/${projectId}/events/stream?after=${cursor}`, { - credentials: 'include', + const response = await request(`/api/projects/${projectId}/events/stream?after=${cursor}`, { signal: controller.signal, headers: { Accept: 'application/x-ndjson' } }) if (response.status === 401) { - location.assign('/login') return } if (!response.ok || !response.body) throw new Error(`事件流连接失败(${response.status})`) diff --git a/web-ui/src/components/AgentTimeline.test.ts b/web-ui/packages/agent/src/components/AgentTimeline.test.ts similarity index 83% rename from web-ui/src/components/AgentTimeline.test.ts rename to web-ui/packages/agent/src/components/AgentTimeline.test.ts index 909734c..f8ed3c1 100644 --- a/web-ui/src/components/AgentTimeline.test.ts +++ b/web-ui/packages/agent/src/components/AgentTimeline.test.ts @@ -39,10 +39,10 @@ describe('AgentTimeline', () => { const events = [ { id: 1, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'a', toolCallName: 'read_file' }, createdAt: '2026-08-24T10:00:00Z' }, { id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'a', delta: '{"path":"inputs/report.md"}' }, createdAt: '2026-08-24T10:00:00Z' }, - { id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'a' }, createdAt: '2026-08-24T10:00:01Z' }, + { id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'a', content: '材料正文' }, createdAt: '2026-08-24T10:00:01Z' }, { id: 4, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'b', toolCallName: 'load_skill_through_path' }, createdAt: '2026-08-24T10:00:02Z' }, { id: 5, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'b', delta: '{"skillId":"pdf_imported"}' }, createdAt: '2026-08-24T10:00:02Z' }, - { id: 6, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'b' }, createdAt: '2026-08-24T10:00:03Z' }, + { id: 6, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'b', content: 'Skill 已加载' }, createdAt: '2026-08-24T10:00:03Z' }, { id: 7, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'c', toolCallName: 'write_file' }, createdAt: '2026-08-24T10:00:04Z' }, { id: 8, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'c', delta: '{"path":"work/material-check.json"}' }, createdAt: '2026-08-24T10:00:04Z' } ] @@ -128,4 +128,24 @@ describe('AgentTimeline', () => { expect(wrapper.text()).toContain('已确认材料检验 · 1 项按规划假设继续 · 1 项待确认 · 1 项已补充') }) + it('增量更新不重读历史事件,结束事件不会覆盖失败状态', async () => { + let historyReads = 0 + const started = { id: 1, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', + get payload() { historyReads++; return { toolCallId: 't', toolCallName: 'execute' } }, createdAt: '2026-08-24T10:00:00Z' } + const wrapper = shallowMount(AgentTimeline, { props: { events: [started], running: true, projectId: 'p' } }) + expect(wrapper.text()).toContain('正在调用') + const reads = historyReads + const failure = { id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', + payload: { toolCallId: 't', content: JSON.stringify({ success: false, exitCode: 1, output: '失败' }) }, createdAt: '2026-08-24T10:00:01Z' } + await wrapper.setProps({ events: [started, failure] }) + expect(historyReads).toBe(reads) + expect(wrapper.findAll('.tool-row.failed')).toHaveLength(1) + await wrapper.setProps({ events: [started, failure, { id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', + payload: { toolCallId: 't' }, createdAt: '2026-08-24T10:00:02Z' }] }) + expect(wrapper.findAll('.tool-row.failed')).toHaveLength(1) + expect(historyReads).toBe(reads) + await wrapper.setProps({ projectId: 'other', events: [] }) + expect(wrapper.findAll('.flow-row')).toHaveLength(0) + }) + }) diff --git a/web-ui/src/components/AgentTimeline.vue b/web-ui/packages/agent/src/components/AgentTimeline.vue similarity index 90% rename from web-ui/src/components/AgentTimeline.vue rename to web-ui/packages/agent/src/components/AgentTimeline.vue index d9c39a8..2e34c47 100644 --- a/web-ui/src/components/AgentTimeline.vue +++ b/web-ui/packages/agent/src/components/AgentTimeline.vue @@ -4,6 +4,7 @@ import { ElImageViewer } from 'element-plus' import { CircleCheck, Document, MagicStick } from '@element-plus/icons-vue' import { Bot, Brain, Images, Wrench } from '@lucide/vue' import type { AgentEvent } from '../api' +import { parseToolResult } from '../eventUtils' const MarkdownRender = defineAsyncComponent(() => import('markstream-vue')) @@ -22,15 +23,30 @@ type FlowItem = | { key: string; runId: string | null; kind: 'tool'; name: string; args: string; detail: string; images: ViewImage[]; status: ActivityStatus; time: string } | { key: string; kind: 'notice'; text: string; tone: 'success' | 'error' | 'info'; time: string } -const flow = computed(() => { - const items: FlowItem[] = [] - const messages = new Map() - const reasoning = new Map() - const tools = new Map() - const asks = new Map>() - const terminalRuns = new Map>() +let processed = 0 +let previousEvents: AgentEvent[] = [] +let previousProject = '' +const items: FlowItem[] = [] +const messages = new Map() +const reasoning = new Map() +const tools = new Map() +const asks = new Map>() +const terminalRuns = new Map>() - for (const event of props.events) { +const nonempty = new Set() + +const flow = computed(() => { + if (previousProject !== props.projectId || props.events.length < processed + || (processed > 0 && (props.events[0] !== previousEvents[0] + || props.events[processed - 1] !== previousEvents[processed - 1]))) { + processed = 0 + items.length = 0 + messages.clear(); reasoning.clear(); tools.clear(); asks.clear(); terminalRuns.clear(); nonempty.clear() + } + previousProject = props.projectId + // 只归并新事件;历史加载或切换项目时重建,保留重连后工具与正文的关联。 + for (let index = processed; index < props.events.length; index++) { + const event = props.events[index]! const payload = event.payload || {} const eventKey = (id: unknown) => `${event.runId || 'none'}:${String(id)}` if (event.type === 'TEXT_MESSAGE_START') { @@ -53,6 +69,8 @@ const flow = computed(() => { items.push(item) } item.text += String(payload.delta || payload.content || '') + if (cleanText(item.text)) nonempty.add(item) + else nonempty.delete(item) } else if (event.type === 'TEXT_MESSAGE_END') { const item = messages.get(eventKey(payload.messageId || 'current')) if (item) item.final = true @@ -74,6 +92,8 @@ const flow = computed(() => { items.push(item) } item.text += String(payload.delta || payload.content || '') + if (cleanText(item.text)) nonempty.add(item) + else nonempty.delete(item) } else if (event.type === 'REASONING_MESSAGE_END' || event.type === 'REASONING_END') { const item = reasoning.get(eventKey(payload.messageId || payload.reasoningId || event.id)) if (item) item.status = 'done' @@ -89,11 +109,12 @@ const flow = computed(() => { } else if (event.type === 'TOOL_CALL_ARGS' || event.type === 'TOOL_CALL_CHUNK') { const item = tools.get(eventKey(payload.toolCallId || payload.id || '')) if (item) item.args += String(payload.delta || payload.args || '') - } else if (event.type === 'TOOL_CALL_RESULT' || event.type === 'TOOL_CALL_END') { + } else if (event.type === 'TOOL_CALL_RESULT') { const item = tools.get(eventKey(payload.toolCallId || payload.id || '')) if (item) { - const result = String(payload.content || payload.result || '') - item.status = /(?:执行失败|(?:^|\n)"?(?:error:|failed\b|exit code:\s*[1-9]))/i.test(result.trim()) ? 'failed' : 'done' + const parsed = parseToolResult(payload.content ?? payload.result ?? '', item.name) + const result = parsed.text + item.status = parsed.failed ? 'failed' : 'done' if (result) { item.detail = summarize(result, 360) if (item.name === 'document_view') item.images = parseViewImages(result) @@ -111,7 +132,7 @@ const flow = computed(() => { terminalRuns.set(event.runId, 'failed') closeActive(event.runId, 'failed', reasoning, tools) items.push({ - key: `e-${event.id}`, kind: 'notice', text: '执行遇到问题,已保留现有进度', + key: `e-${event.id}`, kind: 'notice', text: String(payload.message || '执行遇到问题,已保留现有进度'), tone: 'error', time: formatTime(event.createdAt) }) } else if (event.type === 'MODEL_RETRY') { @@ -137,9 +158,11 @@ const flow = computed(() => { closeActive(event.runId, 'done', reasoning, tools) } } + processed = props.events.length + previousEvents = props.events for (const [runId, status] of terminalRuns) closeActive(runId, status, reasoning, tools) for (const item of messages.values()) if (terminalRuns.has(item.runId)) item.final = true - const filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || cleanText(item.text)) + const filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || nonempty.has(item)) const identifiedRuns = new Set() for (const item of filtered) { if (item.kind === 'message' && !identifiedRuns.has(item.runId)) { diff --git a/web-ui/src/components/MaterialAskCard.vue b/web-ui/packages/agent/src/components/MaterialAskCard.vue similarity index 100% rename from web-ui/src/components/MaterialAskCard.vue rename to web-ui/packages/agent/src/components/MaterialAskCard.vue diff --git a/web-ui/src/components/PlanCard.vue b/web-ui/packages/agent/src/components/PlanCard.vue similarity index 100% rename from web-ui/src/components/PlanCard.vue rename to web-ui/packages/agent/src/components/PlanCard.vue diff --git a/web-ui/src/App.vue b/web-ui/packages/agent/src/components/ProjectSidebar.vue similarity index 70% rename from web-ui/src/App.vue rename to web-ui/packages/agent/src/components/ProjectSidebar.vue index d708285..accb620 100644 --- a/web-ui/src/App.vue +++ b/web-ui/packages/agent/src/components/ProjectSidebar.vue @@ -1,8 +1,7 @@ - diff --git a/web-ui/src/eventCache.ts b/web-ui/packages/agent/src/eventCache.ts similarity index 100% rename from web-ui/src/eventCache.ts rename to web-ui/packages/agent/src/eventCache.ts diff --git a/web-ui/packages/agent/src/eventUtils.test.ts b/web-ui/packages/agent/src/eventUtils.test.ts new file mode 100644 index 0000000..bd86ff7 --- /dev/null +++ b/web-ui/packages/agent/src/eventUtils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import type { AgentEvent } from './api' +import { appendUniqueEvents, parseToolResult } from './eventUtils' + +const event = (id: number): AgentEvent => ({ + id, + projectId: 'project', + runId: 'run', + type: 'TEXT_MESSAGE_CONTENT', + payload: { delta: String(id) }, + createdAt: '2026-08-24T00:00:00Z' +}) + +describe('appendUniqueEvents', () => { + it('保留顺序并忽略重放事件', () => { + expect(appendUniqueEvents([event(1), event(2)], [event(2), event(3)]).map(item => item.id)) + .toEqual([1, 2, 3]) + }) +}) + + +describe('parseToolResult', () => { + it('优先使用结构化结果并识别旧的脚本错误', () => { + expect(parseToolResult(JSON.stringify(JSON.stringify({ success: false, exitCode: 1, output: '错误' })), 'execute').failed).toBe(true) + expect(parseToolResult({ success: true, exitCode: 0, output: '示例 TypeError:' }, 'execute').failed).toBe(false) + expect(parseToolResult(JSON.stringify('Exit code: 0\n\nTypeError: children is not iterable'), 'execute').failed).toBe(true) + expect(parseToolResult(JSON.stringify('Exit code: 0\n\nSyntaxError: Unexpected token'), 'execute').failed).toBe(true) + expect(parseToolResult('TypeError: 文档中的示例', 'read_file').failed).toBe(false) + expect(parseToolResult('{"success":false,"error":"文件中的业务数据"}', 'read_file').failed).toBe(false) + expect(parseToolResult('document_view_result={"images":[],"errors":["无法渲染"]}', 'document_view').failed).toBe(true) + }) +}) diff --git a/web-ui/packages/agent/src/eventUtils.ts b/web-ui/packages/agent/src/eventUtils.ts new file mode 100644 index 0000000..75fd085 --- /dev/null +++ b/web-ui/packages/agent/src/eventUtils.ts @@ -0,0 +1,35 @@ +import type { AgentEvent } from './api' + +/** 合并重连批次并按事件主键去重。 */ +export function appendUniqueEvents(current: AgentEvent[], incoming: AgentEvent[]) { + if (!incoming.length) return current + const lastId = current.at(-1)?.id + if (lastId == null || incoming[0].id > lastId) return current.concat(incoming) + const ids = new Set(current.map(event => event.id)) + return current.concat(incoming.filter(event => !ids.has(event.id))) +} + +/** 新工具使用结构化状态;解码 SDK 包装后兼容已保存的旧事件。 */ +export function parseToolResult(content: unknown, toolName: string) { + let value = content + for (let i = 0; i < 2 && typeof value === 'string'; i++) { + try { value = JSON.parse(value) } catch { break } + } + const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '' + if ((toolName === 'execute' || toolName === 'edit_file') && value && typeof value === 'object') { + const result = value as Record + if (typeof result.success === 'boolean') return { text, failed: !result.success } + if (typeof result.exitCode === 'number') return { text, failed: result.exitCode !== 0 } + if (result.isError === true || result.error) return { text, failed: true } + } + if (toolName === 'document_view' && text.includes('document_view_result=')) { + try { + const metadata = JSON.parse(text.split('document_view_result=')[1]!.split('\n')[0]!) + if (Array.isArray(metadata.errors) && metadata.errors.length) return { text, failed: true } + } catch { return { text, failed: true } } + } + const error = /(?:执行失败|(?:^|\n)\s*(?:error:|failed\b|exit code:\s*-?[1-9]))/i.test(text.trim()) + const legacyShellError = toolName === 'execute' + && /(?:^|\n)(?:\w*Error:|Traceback \(most recent call last\):)/.test(text) + return { text, failed: error || legacyShellError } +} diff --git a/web-ui/packages/agent/src/index.ts b/web-ui/packages/agent/src/index.ts new file mode 100644 index 0000000..3a826c7 --- /dev/null +++ b/web-ui/packages/agent/src/index.ts @@ -0,0 +1,8 @@ +export { default as ProjectSidebar } from './components/ProjectSidebar.vue' + +export const agentRoutes = [ + { path: '/projects', component: () => import('./pages/ProjectPage.vue') }, + { path: '/projects/:id', component: () => import('./pages/ProjectPage.vue') }, + { path: '/models', component: () => import('./pages/ModelsPage.vue') }, + { path: '/skills', component: () => import('./pages/SkillsPage.vue') } +] diff --git a/web-ui/src/pages/ModelsPage.test.ts b/web-ui/packages/agent/src/pages/ModelsPage.test.ts similarity index 100% rename from web-ui/src/pages/ModelsPage.test.ts rename to web-ui/packages/agent/src/pages/ModelsPage.test.ts diff --git a/web-ui/src/pages/ModelsPage.vue b/web-ui/packages/agent/src/pages/ModelsPage.vue similarity index 100% rename from web-ui/src/pages/ModelsPage.vue rename to web-ui/packages/agent/src/pages/ModelsPage.vue diff --git a/web-ui/src/pages/ProjectPage.test.ts b/web-ui/packages/agent/src/pages/ProjectPage.test.ts similarity index 100% rename from web-ui/src/pages/ProjectPage.test.ts rename to web-ui/packages/agent/src/pages/ProjectPage.test.ts diff --git a/web-ui/src/pages/ProjectPage.vue b/web-ui/packages/agent/src/pages/ProjectPage.vue similarity index 100% rename from web-ui/src/pages/ProjectPage.vue rename to web-ui/packages/agent/src/pages/ProjectPage.vue diff --git a/web-ui/src/pages/SkillsPage.vue b/web-ui/packages/agent/src/pages/SkillsPage.vue similarity index 100% rename from web-ui/src/pages/SkillsPage.vue rename to web-ui/packages/agent/src/pages/SkillsPage.vue diff --git a/web-ui/packages/agent/tsconfig.json b/web-ui/packages/agent/tsconfig.json new file mode 100644 index 0000000..4a890a3 --- /dev/null +++ b/web-ui/packages/agent/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ] +} diff --git a/web-ui/packages/common/package.json b/web-ui/packages/common/package.json new file mode 100644 index 0000000..aa3afbd --- /dev/null +++ b/web-ui/packages/common/package.json @@ -0,0 +1,14 @@ +{ + "name": "@manuagent/common", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles.css": "./src/styles.css" + }, + "dependencies": {}, + "scripts": { + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + } +} diff --git a/web-ui/packages/common/src/index.test.ts b/web-ui/packages/common/src/index.test.ts new file mode 100644 index 0000000..3f909d7 --- /dev/null +++ b/web-ui/packages/common/src/index.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { api, request, resetCsrf, setUnauthorizedHandler } from './index' + +afterEach(() => { + resetCsrf() + setUnauthorizedHandler(() => {}) + vi.unstubAllGlobals() +}) + +it('shares CSRF between JSON and streaming requests while preserving cancellation', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(Response.json({ headerName: 'X-CSRF-TOKEN', token: 'test-token' })) + .mockResolvedValueOnce(Response.json({ id: 'project' })) + .mockResolvedValueOnce(new Response('data: test\n\n')) + vi.stubGlobal('fetch', fetch) + await api('/api/projects', { method: 'POST', body: '{}' }) + const signal = new AbortController().signal + const response = await request('/api/projects/project/agent', { method: 'POST', body: '{}', signal }) + expect(await response.text()).toBe('data: test\n\n') + expect(fetch).toHaveBeenCalledTimes(3) + for (const index of [1, 2]) { + expect(fetch.mock.calls[index][1].headers.get('X-CSRF-TOKEN')).toBe('test-token') + expect(fetch.mock.calls[index][1].credentials).toBe('include') + } + expect(fetch.mock.calls[2][1].signal).toBe(signal) +}) + +it('redirects unauthorized streams and reports expired JSON sessions without redirecting login failures', async () => { + const unauthorized = vi.fn() + setUnauthorizedHandler(unauthorized) + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response('{}', { status: 401 })))) + expect((await request('/api/events')).status).toBe(401) + await expect(api('/api/projects')).rejects.toThrow('登录状态已失效') + await request('/api/auth/login', { method: 'POST', body: '{}' }) + expect(unauthorized).toHaveBeenCalledTimes(2) +}) diff --git a/web-ui/packages/common/src/index.ts b/web-ui/packages/common/src/index.ts new file mode 100644 index 0000000..e639038 --- /dev/null +++ b/web-ui/packages/common/src/index.ts @@ -0,0 +1,34 @@ +let csrf: { token: string; headerName: string } | null = null +let onUnauthorized = () => {} + +export function setUnauthorizedHandler(handler: () => void) { + onUnauthorized = handler +} + +export function resetCsrf() { + csrf = null +} + +export async function request(path: string, options: RequestInit = {}): Promise { + const method = (options.method || 'GET').toUpperCase() + const headers = new Headers(options.headers) + if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && path !== '/api/auth/login') { + if (!csrf) csrf = await api('/api/auth/csrf') + headers.set(csrf!.headerName, csrf!.token) + } + if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json') + const response = await fetch(path, { credentials: 'include', ...options, headers }) + if (response.status === 401 && path !== '/api/auth/login') onUnauthorized() + return response +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const response = await request(path, options) + if (response.status === 401 && path !== '/api/auth/login') throw new Error('登录状态已失效') + if (!response.ok) { + const body = await response.json().catch(() => ({ message: `请求失败(${response.status})` })) + throw new Error(body.message || `请求失败(${response.status})`) + } + if (response.status === 204) return null as T + return response.json() +} diff --git a/web-ui/src/styles.css b/web-ui/packages/common/src/styles.css similarity index 100% rename from web-ui/src/styles.css rename to web-ui/packages/common/src/styles.css diff --git a/web-ui/packages/common/tsconfig.json b/web-ui/packages/common/tsconfig.json new file mode 100644 index 0000000..4a890a3 --- /dev/null +++ b/web-ui/packages/common/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ] +} diff --git a/web-ui/src/eventUtils.test.ts b/web-ui/src/eventUtils.test.ts deleted file mode 100644 index 05e75e3..0000000 --- a/web-ui/src/eventUtils.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentEvent } from './api' -import { appendUniqueEvents } from './eventUtils' - -const event = (id: number): AgentEvent => ({ - id, - projectId: 'project', - runId: 'run', - type: 'TEXT_MESSAGE_CONTENT', - payload: { delta: String(id) }, - createdAt: '2026-08-24T00:00:00Z' -}) - -describe('appendUniqueEvents', () => { - it('保留顺序并忽略重放事件', () => { - expect(appendUniqueEvents([event(1), event(2)], [event(2), event(3)]).map(item => item.id)) - .toEqual([1, 2, 3]) - }) -}) diff --git a/web-ui/src/eventUtils.ts b/web-ui/src/eventUtils.ts deleted file mode 100644 index 2dcb2c9..0000000 --- a/web-ui/src/eventUtils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AgentEvent } from './api' - -/** 合并重连批次并按事件主键去重。 */ -export function appendUniqueEvents(current: AgentEvent[], incoming: AgentEvent[]) { - if (!incoming.length) return current - const lastId = current.at(-1)?.id - if (lastId == null || incoming[0].id > lastId) return current.concat(incoming) - const ids = new Set(current.map(event => event.id)) - return current.concat(incoming.filter(event => !ids.has(event.id))) -} diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts deleted file mode 100644 index 05a1bcd..0000000 --- a/web-ui/src/router.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router' - -export const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } }, - { path: '/', redirect: '/projects' }, - { path: '/projects', component: () => import('./pages/ProjectPage.vue') }, - { path: '/projects/:id', component: () => import('./pages/ProjectPage.vue') }, - { path: '/models', component: () => import('./pages/ModelsPage.vue') }, - { path: '/skills', component: () => import('./pages/SkillsPage.vue') } - ] -}) diff --git a/web-ui/tsconfig.app.json b/web-ui/tsconfig.base.json similarity index 72% rename from web-ui/tsconfig.app.json rename to web-ui/tsconfig.base.json index c94ec65..3e58785 100644 --- a/web-ui/tsconfig.app.json +++ b/web-ui/tsconfig.base.json @@ -11,8 +11,13 @@ "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"] - }, - "include": ["src/**/*.ts", "src/**/*.vue"] + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client" + ] + } } diff --git a/web-ui/tsconfig.json b/web-ui/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/web-ui/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -}