Compare commits

...

9 Commits

Author SHA1 Message Date
Zhu Junhao
d392436620 构建:补充离线部署配置并排除镜像包 2026-09-05 10:37:43 +08:00
Zhu Junhao
8c3883e356 修复:完善流式输出滚动跟随 2026-09-05 10:37:43 +08:00
Zhu Junhao
4615df3f90 构建:改用预构建的 Agent Runtime 镜像 2026-09-04 15:45:23 +08:00
Zhu Junhao
595c8da595 构建:新增完整容器化部署 2026-09-04 15:31:17 +08:00
Zhu Junhao
99d16bb3b9 fix: 恢复最新消息自动跟随
点击回到底部后重新启用尾部跟随,并避免程序滚动过程被误判为用户上滚。监听流式内容尺寸变化,确保后续消息继续贴住最新位置。
2026-09-03 16:18:36 +08:00
Zhu Junhao
7055f62da1 fix: 同步 Agent Run 绑定模型
项目启动、材料确认、规划确认和重试后立即使用接口返回的 modelConfigId 更新页面,避免运行中的任务错误显示为尚未选择模型。
2026-09-03 11:08:43 +08:00
Zhu Junhao
4759759d02 feat: 优化项目 Agent 工作区布局
增加四阶段进度、项目上下文侧栏和更清晰的执行卡片层级,同时保留现有流式事件接收、确认与恢复流程。
2026-09-03 10:53:59 +08:00
Zhu Junhao
988c0fe555 feat: 完善多模型配置与运行切换
将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
2026-09-03 10:46:25 +08:00
Zhu Junhao
e968a8ddc1 重构:使用 MyBatis-Flex 迁移应用 ORM
将应用自管表的 JdbcClient 数据访问迁移为实体、Mapper、构造器查询和必要的显式 SQL。

保留 AgentScope 自管表及原有业务语义,并补充事务、查询与数据库集成测试。
2026-08-31 12:13:06 +08:00
89 changed files with 6650 additions and 925 deletions

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# 复制为 .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

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# 容器内执行的 Shell 脚本必须使用 LF避免 Windows 检出时生成 CRLF 导致解释器无法识别。
*.sh text eol=lf
# Docker 与 Nginx 配置统一使用 LF便于在 Linux 容器内直接加载。
Dockerfile text eol=lf
*.conf text eol=lf

4
.gitignore vendored
View File

@@ -14,4 +14,8 @@ __pycache__/
*.py[cod] *.py[cod]
.env.* .env.*
!.env.example !.env.example
deepseek_key.txt
test_data/ test_data/
# 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。
deploy/*.tar

View File

@@ -11,23 +11,86 @@
## 本地启动 ## 本地启动
```bash ```bash
docker compose --profile build-only build agent-runtime docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox
docker compose up -d postgres docker compose up -d postgres
mvn -q -f server/pom.xml spring-boot:run mvn -q -f server/pom.xml spring-boot:run
npm --prefix client install npm --prefix web-ui install
npm --prefix client run dev npm --prefix web-ui run dev
``` ```
打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123` 本地开发服务器默认使用 Vite 配置的端口。容器化部署固定从 <http://127.0.0.1:5173> 访问
项目根目录的 `deepseek_key.txt``dashscope_key.txt` 分别供模型与百炼知识库使用模型、Skill 也可在页面内查看或配置。 模型连接只能在“模型配置”页面新增并持久化到 PostgreSQLAPI Key 会使用 `APP_MASTER_KEY`
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
启动后端前必须设置模型密钥加密主密钥:
```powershell
$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
```
## Docker Compose 部署
部署包含 Nginx 前端、Spring Boot 后端、Agent Runtime 镜像和 PostgreSQL。Agent Runtime
不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `agent-runtime` 服务只校验
镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。
1. 在开发机构建并导出 Agent Runtime 镜像:
```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
```
将 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
```
2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。
```bash
cp .env.example .env
```
3. 确认 `MANUAGENT_DATA_ROOT` 是 Docker 宿主机上的绝对路径。这个路径会以完全相同的路径挂载到
后端容器,供动态 Agent Runtime 继续挂载项目材料、工作目录和产物。Linux 默认值为
`/srv/manuagent/data`。
4. 构建前后端并启动完整服务:
```bash
docker compose -f docker-compose.yml up -d --build
docker compose -f docker-compose.yml ps
```
5. 打开 <http://127.0.0.1:5173>,使用 `.env` 中的 `APP_ADMIN_USERNAME` 和
`APP_ADMIN_PASSWORD` 登录。查看日志或停止服务:
```bash
docker compose -f docker-compose.yml logs -f backend
docker compose -f docker-compose.yml down
```
默认端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173可仅在本机
验证时临时执行 `$env:FRONTEND_PORT = '15173'`Linux 服务器部署应保留默认的 5173。
PostgreSQL 数据保存在 `postgres-data` 命名卷中项目材料、Agent 工作区、快照和生成文件保存在
`MANUAGENT_DATA_ROOT`。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除
PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的
Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将
`SESSION_COOKIE_SECURE` 设为 `true`。
## 验证 ## 验证
```bash ```bash
mvn -q -f server/pom.xml test mvn -q -f server/pom.xml test
npm --prefix client run test -- --run npm --prefix web-ui run test
npm --prefix client run build npm --prefix web-ui run build
docker compose -f docker-compose.yml config --quiet
``` ```
产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。 产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。

View File

@@ -1,25 +0,0 @@
services:
agent-runtime:
image: smart-factory-agent-runtime:0.1.0
build:
context: sandbox
profiles: ["build-only"]
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: smart_factory_agent
POSTGRES_USER: smart_factory
POSTGRES_PASSWORD: smart_factory
ports:
- "127.0.0.1:54330:5432"
volumes:
- smart-factory-pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U smart_factory -d smart_factory_agent"]
interval: 3s
timeout: 3s
retries: 20
volumes:
smart-factory-pg:

View File

@@ -1 +0,0 @@
sk-8d1419754bce4306bc99854ee5ccd505

22
deploy/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# 复制为 .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

130
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,130 @@
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

130
docker-compose.yml Normal file
View File

@@ -0,0 +1,130 @@
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

6
server/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
target
.idea
*.iml
*.log
.env
.env.*

37
server/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# Maven 构建阶段使用与项目一致的 JDK 21并利用 BuildKit 缓存减少重复下载依赖的时间。
FROM maven:3.9.13-eclipse-temurin-21 AS builder
WORKDIR /workspace
COPY pom.xml ./
COPY src ./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
# curl 用于容器健康检查gosu 用于完成目录和 Docker Socket 权限初始化后降权运行 Java。
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
RUN groupadd --gid 10001 manuagent \
&& useradd --uid 10001 --gid 10001 --create-home --shell /bin/bash manuagent \
&& mkdir -p /opt/manuagent /srv/manuagent/data \
&& chown -R manuagent:manuagent /opt/manuagent /srv/manuagent
WORKDIR /opt/manuagent
COPY --from=builder /workspace/target/*.jar app.jar
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["java", "-XX:MaxRAMPercentage=75.0", "-Djava.security.egd=file:/dev/urandom", "-jar", "/opt/manuagent/app.jar"]

View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -eu
# Agent Runtime 的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。
data_root="${APP_DATA_ROOT:-/srv/manuagent/data}"
mkdir -p "$data_root"
chown manuagent:manuagent "$data_root"
# Docker Socket 的组 ID 在不同 Linux 发行版和 Docker Desktop 中并不固定。
# 启动时读取真实组 ID 并把低权限应用用户加入对应组,避免以 root 身份运行 Spring Boot。
docker_socket="/var/run/docker.sock"
if [ ! -S "$docker_socket" ]; then
echo "错误:未挂载 $docker_socket,后端无法创建 Agent Runtime 容器。" >&2
exit 1
fi
docker_gid="$(stat -c '%g' "$docker_socket")"
docker_group="$(getent group "$docker_gid" | cut -d: -f1 || true)"
if [ -z "$docker_group" ]; then
docker_group="docker-host"
groupadd --gid "$docker_gid" "$docker_group"
fi
usermod -aG "$docker_group" manuagent
exec gosu manuagent "$@"

View File

@@ -19,6 +19,7 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<agentscope.version>2.0.1</agentscope.version> <agentscope.version>2.0.1</agentscope.version>
<mybatis-flex.version>1.11.8</mybatis-flex.version>
<tika.version>3.2.3</tika.version> <tika.version>3.2.3</tika.version>
<testcontainers.version>1.21.4</testcontainers.version> <testcontainers.version>1.21.4</testcontainers.version>
</properties> </properties>
@@ -40,6 +41,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId> <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
<version>${mybatis-flex.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId> <artifactId>flyway-database-postgresql</artifactId>

View File

@@ -1,6 +1,8 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -95,8 +97,27 @@ public class AgentController {
* @return 新恢复 Run * @return 新恢复 Run
*/ */
@PostMapping("/runs/resume") @PostMapping("/runs/resume")
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) { public AgentRunService.RunView resume(
return runService.resume(projectId, principal); @PathVariable UUID projectId,
@Valid @RequestBody(required = false) ResumeInput input,
Principal principal) {
return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal);
}
/**
* 将运行中的任务受控切换到替代模型。
*
* @param projectId 项目 ID
* @param input 替代模型
* @param principal 当前用户
* @return 绑定替代模型的新恢复 Run
*/
@PostMapping("/runs/switch-model")
public AgentRunService.RunView switchModel(
@PathVariable UUID projectId,
@Valid @RequestBody ResumeInput input,
Principal principal) {
return runService.switchModel(projectId, input.modelConfigId(), principal);
} }
/** /**
@@ -138,4 +159,12 @@ public class AgentController {
@RequestParam(defaultValue = "0") long after) { @RequestParam(defaultValue = "0") long after) {
return eventService.streamAfter(projectId, after); return eventService.streamAfter(projectId, after);
} }
/**
* 恢复或切换任务时指定的模型。
*
* @param modelConfigId 目标模型配置 ID
*/
public record ResumeInput(@NotNull UUID modelConfigId) {
}
} }

View File

@@ -3,13 +3,13 @@ package tech.easyflow.manuagent.agent;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -17,6 +17,8 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
/** /**
* 持久化并查询项目级 AG-UI 事件。 * 持久化并查询项目级 AG-UI 事件。
@@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers;
@Service @Service
public class AgentEventService { public class AgentEventService {
private final JdbcClient jdbc; private final AgentEventMapper eventMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>(); private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
/** /**
* 创建事件服务。 * 创建事件服务。
* *
* @param jdbc JDBC 客户端 * @param eventMapper Agent 事件 Mapper
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) { public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
this.jdbc = jdbc; this.eventMapper = eventMapper;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -53,18 +55,14 @@ public class AgentEventService {
ObjectNode object = value.isObject() ObjectNode object = value.isObject()
? (ObjectNode) value ? (ObjectNode) value
: objectMapper.createObjectNode().set("value", value); : objectMapper.createObjectNode().set("value", value);
EventView event = jdbc.sql(""" AgentEventEntity entity = new AgentEventEntity();
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload) entity.setProjectId(projectId);
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb)) entity.setRunId(runId);
RETURNING id, project_id, run_id, event_type, payload, created_at entity.setEventType(eventType);
""") entity.setEventId(UUID.randomUUID().toString());
.param("projectId", projectId) entity.setPayloadJson(object.toString());
.param("runId", runId) // 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
.param("eventType", eventType) EventView event = toEventView(eventMapper.insertReturning(entity));
.param("eventId", UUID.randomUUID().toString())
.param("payload", object.toString())
.query(this::mapEvent)
.single();
publishAfterCommit(event); publishAfterCommit(event);
return event; return event;
} }
@@ -77,19 +75,24 @@ public class AgentEventService {
* @param limit 最大返回数量 * @param limit 最大返回数量
* @return 有序事件 * @return 有序事件
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public List<EventView> listAfter(UUID projectId, long afterId, int limit) { public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
return jdbc.sql(""" QueryWrapper query = QueryWrapper.create()
SELECT id, project_id, run_id, event_type, payload, created_at .select(
FROM app.agent_event AgentEventEntity::getId,
WHERE project_id = :projectId AND id > :afterId AgentEventEntity::getProjectId,
ORDER BY id AgentEventEntity::getRunId,
LIMIT :limit AgentEventEntity::getEventType,
""") AgentEventEntity::getPayloadJson,
.param("projectId", projectId) AgentEventEntity::getCreatedAt)
.param("afterId", Math.max(0, afterId)) .where(AgentEventEntity::getProjectId).eq(projectId)
.param("limit", Math.clamp(limit, 1, 1000)) .and(AgentEventEntity::getId).gt(Math.max(0, afterId))
.query(this::mapEvent) .orderBy(AgentEventEntity::getId).asc()
.list(); .limit(Math.clamp(limit, 1, 1000));
return eventMapper.selectListByQuery(query)
.stream()
.map(this::toEventView)
.toList();
} }
/** /**
@@ -157,17 +160,23 @@ public class AgentEventService {
} }
} }
private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { /**
* 将持久化实体转换成对外事件视图,并在边界处解析 JSONB 文本。
*
* @param entity 数据库事件实体
* @return 可供 REST 与事件流输出的事件
*/
private EventView toEventView(AgentEventEntity entity) {
try { try {
return new EventView( return new EventView(
rs.getLong("id"), entity.getId(),
rs.getObject("project_id", UUID.class), entity.getProjectId(),
rs.getObject("run_id", UUID.class), entity.getRunId(),
rs.getString("event_type"), entity.getEventType(),
objectMapper.readTree(rs.getString("payload")), objectMapper.readTree(entity.getPayloadJson()),
rs.getObject("created_at", OffsetDateTime.class)); entity.getCreatedAt());
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) { } catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception); throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
} }
} }

View File

@@ -88,7 +88,8 @@ public class AgentExecutionService {
.runId(run.id().toString()) .runId(run.id().toString())
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt))) .messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
.build(); .build();
try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) { try (AgentFactory.AgentHandle handle = agentFactory.create(
project.id(), run.modelConfigId(), skillService.enabledNames())) {
handle.adapter().run(input) handle.adapter().run(input)
.takeUntilOther(stopSignal) .takeUntilOther(stopSignal)
.bufferTimeout(64, Duration.ofMillis(120)) .bufferTimeout(64, Duration.ofMillis(120))

View File

@@ -78,11 +78,13 @@ public class AgentFactory {
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。 * 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId Run 创建时绑定的模型配置 ID
* @param enabledSkills 当前启用 Skill * @param enabledSkills 当前启用 Skill
* @return 需要在流结束后关闭的 Agent 句柄 * @return 需要在流结束后关闭的 Agent 句柄
*/ */
public AgentHandle create(UUID projectId, String[] enabledSkills) { public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) {
ModelService.ModelSecret model = modelService.defaultModelSecret(); // 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置全局默认模型只参与新 Run 的选择。
ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId);
OpenAIChatModel chatModel = OpenAIChatModel.builder() OpenAIChatModel chatModel = OpenAIChatModel.builder()
.apiKey(model.apiKey()) .apiKey(model.apiKey())
.baseUrl(model.baseUrl()) .baseUrl(model.baseUrl())

View File

@@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/** /**
* 驱动材料检验、规划 Ask 和自动编写 Run。 * 驱动材料检验、规划 Ask 和自动编写 Run。
@@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks;
public class AgentRunService { public class AgentRunService {
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class); private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
private final JdbcClient jdbc; private final AgentRunMapper runMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AgentExecutionService executionService; private final AgentExecutionService executionService;
private final AgentOutputService outputService; private final AgentOutputService outputService;
@@ -52,7 +52,7 @@ public class AgentRunService {
/** /**
* 创建 Agent Run 服务。 * 创建 Agent Run 服务。
* *
* @param jdbc JDBC 客户端 * @param runMapper Agent Run Mapper
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
* @param executionService Agent 执行服务 * @param executionService Agent 执行服务
* @param outputService Agent 结构化输出服务 * @param outputService Agent 结构化输出服务
@@ -66,7 +66,7 @@ public class AgentRunService {
* @param transactions 编程式事务模板 * @param transactions 编程式事务模板
*/ */
public AgentRunService( public AgentRunService(
JdbcClient jdbc, AgentRunMapper runMapper,
ObjectMapper objectMapper, ObjectMapper objectMapper,
AgentExecutionService executionService, AgentExecutionService executionService,
AgentOutputService outputService, AgentOutputService outputService,
@@ -78,7 +78,7 @@ public class AgentRunService {
ArtifactService artifactService, ArtifactService artifactService,
ExecutorService applicationExecutor, ExecutorService applicationExecutor,
TransactionTemplate transactions) { TransactionTemplate transactions) {
this.jdbc = jdbc; this.runMapper = runMapper;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.executionService = executionService; this.executionService = executionService;
this.outputService = outputService; this.outputService = outputService;
@@ -207,15 +207,7 @@ public class AgentRunService {
if (run == null || !"RUNNING".equals(run.status())) { if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
} }
int updated = jdbc.sql(""" int updated = runMapper.interruptRunning(run.id());
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated); requireTerminalUpdate(updated);
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED")); eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
onCommit(() -> { onCommit(() -> {
@@ -236,6 +228,19 @@ public class AgentRunService {
*/ */
@Transactional @Transactional
public RunView resume(UUID projectId, Principal principal) { public RunView resume(UUID projectId, Principal principal) {
return resume(projectId, null, principal);
}
/**
* 从已中断 Run 的原阶段继续,并可显式选择本次恢复使用的模型。
*
* @param projectId 项目 ID
* @param modelConfigId 替代模型;为空时使用当前默认模型
* @param principal 当前用户
* @return 新的恢复 Run
*/
@Transactional
public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId); ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId); RunView interrupted = latest(projectId);
@@ -243,18 +248,71 @@ public class AgentRunService {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
} }
String phase = runStore.interruptedPhase(interrupted, project); String phase = runStore.interruptedPhase(interrupted, project);
RunView run = runStore.create(projectId, "RESUME", interrupted.id()); RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
projectService.updateStatus(projectId, phase); projectService.updateStatus(projectId, phase);
scheduleResume(project, run, userId, phase);
return run;
}
/**
* 将运行中的任务切换到替代模型,并以新的恢复 Run 保留完整审计边界。
*
* <p>旧 Run 在事务内先进入中断状态,新 Run 再绑定目标模型;任一步失败都会整体回滚。
* 目标 ID 可以与旧 Run 相同,以便模型配置被编辑后重新建立客户端并读取最新配置。
* 提交后先取消旧模型流,再从原业务阶段启动新 Run复用相同 threadId 和工作区。</p>
*
* @param projectId 项目 ID
* @param modelConfigId 替代模型 ID
* @param principal 当前用户
* @return 绑定替代模型的新恢复 Run
*/
@Transactional
public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) {
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", "当前没有正在执行的任务");
}
String phase = runStore.interruptedPhase(current, project);
requireTerminalUpdate(runMapper.interruptRunning(current.id()));
eventService.append(projectId, current.id(), "RUN_FINISHED", Map.of(
"outcome", "CANCELLED", "reason", "MODEL_SWITCH"));
RunView replacement = runStore.create(projectId, "RESUME", current.id(), modelConfigId);
eventService.append(projectId, replacement.id(), "MODEL_SWITCHED", Map.of(
"fromModelConfigId", current.modelConfigId(),
"toModelConfigId", replacement.modelConfigId()));
projectService.updateStatus(projectId, phase);
onCommit(() -> {
RunControl control = activeRuns.get(current.id());
if (control != null) {
control.cancel();
}
});
scheduleResume(project, replacement, userId, phase);
return replacement;
}
/**
* 按中断前业务阶段注册恢复任务,所有调用方必须处于创建恢复 Run 的事务中。
*/
private void scheduleResume(
ProjectService.ProjectView project,
RunView run,
UUID userId,
String phase) {
switch (phase) { switch (phase) {
case "MATERIAL_CHECK" -> afterCommit( case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true)); run.id(), () -> executeMaterialRun(project, run, true));
case "PLANNING" -> { case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(projectId); JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
afterCommit(run.id(), () -> executePlanningRun( afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true)); project, run, userId, materialResponse, true));
} }
case "WRITING" -> { case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(projectId); ProjectService.PlanView plan = projectService.currentPlan(project.id());
if (plan == null || !"CONFIRMED".equals(plan.status())) { if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认"); throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
} }
@@ -263,7 +321,6 @@ public class AgentRunService {
default -> throw new ApiException( default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段"); HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
} }
return run;
} }
/** /**
@@ -473,15 +530,7 @@ public class AgentRunService {
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) { private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
transactions.executeWithoutResult(status -> { transactions.executeWithoutResult(status -> {
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt); eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
int updated = jdbc.sql(""" int updated = runMapper.waitForInput(runId, interrupt.toString());
UPDATE app.agent_run
SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb),
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("interrupt", interrupt.toString())
.param("id", runId)
.update();
requireTerminalUpdate(updated); requireTerminalUpdate(updated);
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT")); eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
}); });
@@ -575,14 +624,7 @@ public class AgentRunService {
ArtifactService.ArtifactView artifact = artifactService.publishCandidate( ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
projectId, run.id(), run.startedAt().toInstant(), metadata); projectId, run.id(), run.startedAt().toInstant(), metadata);
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact); eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
int updated = jdbc.sql(""" int updated = runMapper.completeRunning(run.id());
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL,
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated); requireTerminalUpdate(updated);
projectService.updateStatus(projectId, "DELIVERED"); projectService.updateStatus(projectId, "DELIVERED");
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS")); eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
@@ -602,15 +644,7 @@ public class AgentRunService {
: "Agent 执行失败,请稍后重试"; : "Agent 执行失败,请稍后重试";
try { try {
transactions.executeWithoutResult(status -> { transactions.executeWithoutResult(status -> {
int updated = jdbc.sql(""" int updated = runMapper.failRunning(run.id(), message);
UPDATE app.agent_run
SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED',
error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("message", message)
.param("id", run.id())
.update();
if (updated == 1) { if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of( eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
"code", "AGENT_RUN_FAILED", "message", message)); "code", "AGENT_RUN_FAILED", "message", message));
@@ -639,6 +673,7 @@ public class AgentRunService {
* *
* @param id Run ID * @param id Run ID
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
* @param triggerType 触发类型 * @param triggerType 触发类型
* @param status 运行状态 * @param status 运行状态
* @param pendingInterrupt 待处理 Ask JSON * @param pendingInterrupt 待处理 Ask JSON
@@ -649,6 +684,7 @@ public class AgentRunService {
public record RunView( public record RunView(
UUID id, UUID id,
UUID projectId, UUID projectId,
UUID modelConfigId,
String triggerType, String triggerType,
String status, String status,
String pendingInterrupt, String pendingInterrupt,

View File

@@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime; import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; 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;
/** /**
* 集中读写 Agent Run 持久化状态。 * 集中读写 Agent Run 持久化状态。
@@ -17,17 +21,27 @@ import org.springframework.stereotype.Service;
@Service @Service
public class AgentRunStore { public class AgentRunStore {
private final JdbcClient jdbc; private final AgentRunMapper runMapper;
private final AgentEventMapper eventMapper;
private final ModelConfigMapper modelMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** /**
* 创建 Run 状态存储。 * 创建 Run 状态存储。
* *
* @param jdbc JDBC 客户端 * @param runMapper Agent Run Mapper
* @param eventMapper Agent 事件 Mapper
* @param modelMapper 模型配置 Mapper
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) { public AgentRunStore(
this.jdbc = jdbc; AgentRunMapper runMapper,
AgentEventMapper eventMapper,
ModelConfigMapper modelMapper,
ObjectMapper objectMapper) {
this.runMapper = runMapper;
this.eventMapper = eventMapper;
this.modelMapper = modelMapper;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -39,34 +53,63 @@ public class AgentRunStore {
* @param parentRunId 父 Run ID * @param parentRunId 父 Run ID
* @return 新 Run * @return 新 Run
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) { public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
Integer active = jdbc.sql(""" return create(projectId, triggerType, parentRunId, null);
SELECT count(*) FROM app.agent_run }
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
""") /**
.param("projectId", projectId) * 创建绑定指定模型的新 Run未指定模型时使用当前启用的默认模型。
.query(Integer.class) *
.single(); * <p>模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换
* 只影响新 Run不会悄悄改变已经开始的任务。</p>
*
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param parentRunId 父 Run ID
* @param requestedModelId 用户显式选择的模型;为空时使用默认模型
* @return 新 Run
*/
@SuppressWarnings("unchecked") // 查询只投影模型 IDLambdaGetter 可变参数不会引入运行期类型风险。
public AgentRunService.RunView create(
UUID projectId,
String triggerType,
UUID parentRunId,
UUID requestedModelId) {
QueryWrapper activeRuns = QueryWrapper.create()
.where(AgentRunEntity::getProjectId).eq(projectId)
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
long active = runMapper.selectCountByQuery(activeRuns);
if (active > 0) { if (active > 0) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
} }
UUID id = UUID.randomUUID(); QueryWrapper modelQuery = QueryWrapper.create()
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled") .select(ModelConfigEntity::getId);
.query(UUID.class) if (requestedModelId == null) {
.single(); modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
jdbc.sql(""" } else {
INSERT INTO app.agent_run( modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id) }
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId) modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
""") ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
.param("id", id) if (model == null) {
.param("projectId", projectId) if (requestedModelId == null) {
.param("parentRunId", parentRunId) throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
.param("modelId", modelId) }
.param("triggerType", triggerType) throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
.param("traceId", UUID.randomUUID().toString()) }
.update();
return require(id); // 应用层提前生成 Run 与追踪 ID时间字段仍交由数据库默认值统一生成。
AgentRunEntity entity = new AgentRunEntity();
entity.setId(UUID.randomUUID());
entity.setProjectId(projectId);
entity.setParentRunId(parentRunId);
entity.setModelConfigId(model.getId());
entity.setTriggerType(triggerType);
entity.setStatus("RUNNING");
entity.setTraceId(UUID.randomUUID().toString());
runMapper.insertSelective(entity);
return require(entity.getId());
} }
/** /**
@@ -76,11 +119,11 @@ public class AgentRunStore {
* @return 最近 Run不存在时为空 * @return 最近 Run不存在时为空
*/ */
public AgentRunService.RunView latest(UUID projectId) { public AgentRunService.RunView latest(UUID projectId) {
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1") QueryWrapper query = runViewQuery()
.param("projectId", projectId) .where(AgentRunEntity::getProjectId).eq(projectId)
.query(AgentRunStore::mapRun) .orderBy(AgentRunEntity::getCreatedAt).desc()
.optional() .limit(1);
.orElse(null); return toRunView(runMapper.selectOneByQuery(query));
} }
/** /**
@@ -90,10 +133,14 @@ public class AgentRunStore {
* @return Run * @return Run
*/ */
public AgentRunService.RunView require(UUID id) { public AgentRunService.RunView require(UUID id) {
return jdbc.sql(RUN_SELECT + " WHERE id = :id") QueryWrapper query = runViewQuery()
.param("id", id) .where(AgentRunEntity::getId).eq(id);
.query(AgentRunStore::mapRun) AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
.single(); if (run == null) {
// 强制读取仅用于内部已知 ID缺失表示持久化状态异常而不是新增的 404 业务分支。
throw new IllegalStateException("Agent Run 不存在: " + id);
}
return run;
} }
/** /**
@@ -102,13 +149,7 @@ public class AgentRunStore {
* @param runId Run ID * @param runId Run ID
*/ */
public void completeWaiting(UUID runId) { public void completeWaiting(UUID runId) {
int updated = jdbc.sql(""" int updated = runMapper.completeWaiting(runId);
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'WAITING_INPUT'
""")
.param("id", runId)
.update();
if (updated != 1) { if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面"); throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
} }
@@ -142,10 +183,11 @@ public class AgentRunStore {
* @param runId Run ID * @param runId Run ID
*/ */
public void ensureRunning(UUID runId) { public void ensureRunning(UUID runId) {
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id") String status = status(runId);
.param("id", runId) if (status == null) {
.query(String.class) // 保持迁移前强制单条查询对缺失记录的未预期异常语义。
.single(); throw new IllegalStateException("Agent Run 不存在: " + runId);
}
if (!"RUNNING".equals(status)) { if (!"RUNNING".equals(status)) {
throw new AgentExecutionService.RunInterruptedException(); throw new AgentExecutionService.RunInterruptedException();
} }
@@ -158,11 +200,7 @@ public class AgentRunStore {
* @return 是否已停止 * @return 是否已停止
*/ */
public boolean isInterrupted(UUID runId) { public boolean isInterrupted(UUID runId) {
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id") return "INTERRUPTED".equals(status(runId));
.param("id", runId)
.query(Boolean.class)
.optional()
.orElse(false);
} }
/** /**
@@ -173,15 +211,8 @@ public class AgentRunStore {
* @return 业务阶段 * @return 业务阶段
*/ */
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) { public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
return jdbc.sql(""" String phase = eventMapper.selectLatestStartedPhase(run.id());
SELECT payload ->> 'phase' FROM app.agent_event return phase == null ? project.status() : phase;
WHERE run_id = :runId AND event_type = 'RUN_STARTED'
ORDER BY id DESC LIMIT 1
""")
.param("runId", run.id())
.query(String.class)
.optional()
.orElse(project.status());
} }
/** /**
@@ -191,43 +222,75 @@ public class AgentRunStore {
* @return 材料确认 JSON * @return 材料确认 JSON
*/ */
public JsonNode latestMaterialResponse(UUID projectId) { public JsonNode latestMaterialResponse(UUID projectId) {
return jdbc.sql(""" String value = eventMapper.selectLatestMaterialResponseJson(projectId);
SELECT payload::text FROM app.agent_event if (value == null) {
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED' return objectMapper.createObjectNode();
AND jsonb_typeof(payload -> 'decisions') = 'array' }
ORDER BY id DESC LIMIT 1 try {
""") return objectMapper.readTree(value);
.param("projectId", projectId) } catch (JsonProcessingException exception) {
.query(String.class) throw new ApiException(
.optional() HttpStatus.INTERNAL_SERVER_ERROR,
.map(value -> { "MATERIAL_RESPONSE_INVALID",
try { "材料确认记录无法读取");
return objectMapper.readTree(value); }
} catch (JsonProcessingException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MATERIAL_RESPONSE_INVALID",
"材料确认记录无法读取");
}
})
.orElseGet(objectMapper::createObjectNode);
} }
private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum) /**
throws java.sql.SQLException { * 将数据库实体转换为稳定的对外 Run 视图。
*
* @param entity Run 实体;不存在时为空
* @return Run 视图;不存在时为空
*/
private static AgentRunService.RunView toRunView(AgentRunEntity entity) {
if (entity == null) {
return null;
}
return new AgentRunService.RunView( return new AgentRunService.RunView(
rs.getObject("id", UUID.class), entity.getId(),
rs.getObject("project_id", UUID.class), entity.getProjectId(),
rs.getString("trigger_type"), entity.getModelConfigId(),
rs.getString("status"), entity.getTriggerType(),
rs.getString("pending_interrupt"), entity.getStatus(),
rs.getString("error_message"), entity.getPendingInterrupt(),
rs.getObject("started_at", OffsetDateTime.class), entity.getErrorMessage(),
rs.getObject("ended_at", OffsetDateTime.class)); entity.getStartedAt(),
entity.getEndedAt());
} }
private static final String RUN_SELECT = """ /**
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at * 使用 BaseMapper 主键查询读取 Run 状态。
FROM app.agent_run *
"""; * @param runId Run ID
* @return 当前状态Run 不存在时为空
*/
@SuppressWarnings("unchecked") // 这里只投影状态列LambdaGetter 可变参数不会引入运行期类型风险。
private String status(UUID runId) {
QueryWrapper query = QueryWrapper.create()
.select(AgentRunEntity::getStatus)
.where(AgentRunEntity::getId).eq(runId);
AgentRunEntity run = runMapper.selectOneByQuery(query);
return run == null ? null : run.getStatus();
}
/**
* 构造 Agent Run 对外视图所需的最小字段投影。
*
* <p>运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。</p>
*
* @return Run 视图字段查询构造器
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private static QueryWrapper runViewQuery() {
return QueryWrapper.create().select(
AgentRunEntity::getId,
AgentRunEntity::getProjectId,
AgentRunEntity::getModelConfigId,
AgentRunEntity::getTriggerType,
AgentRunEntity::getStatus,
AgentRunEntity::getPendingInterrupt,
AgentRunEntity::getErrorMessage,
AgentRunEntity::getStartedAt,
AgentRunEntity::getEndedAt);
}
} }

View File

@@ -3,9 +3,9 @@ package tech.easyflow.manuagent.agent;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/** /**
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。 * 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
@@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional;
@Order(0) @Order(0)
public class RunRecoveryService implements ApplicationRunner { public class RunRecoveryService implements ApplicationRunner {
private final JdbcClient jdbc; private final AgentRunMapper runMapper;
/** /**
* 创建恢复服务。 * 创建恢复服务。
* *
* @param jdbc JDBC 客户端 * @param runMapper Agent Run Mapper
*/ */
public RunRecoveryService(JdbcClient jdbc) { public RunRecoveryService(AgentRunMapper runMapper) {
this.jdbc = jdbc; this.runMapper = runMapper;
} }
/** /**
@@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner {
@Override @Override
@Transactional @Transactional
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
jdbc.sql(""" runMapper.interruptRunningAfterRestart();
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'RUNNING'
""").update();
} }
} }

View File

@@ -1,6 +1,9 @@
package tech.easyflow.manuagent.artifact; package tech.easyflow.manuagent.artifact;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.common.ApiException; 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.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException; import java.io.IOException;
@@ -18,7 +21,6 @@ import java.util.UUID;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
@@ -27,19 +29,20 @@ import org.springframework.stereotype.Service;
@Service @Service
public class ArtifactService { public class ArtifactService {
private final JdbcClient jdbc; private final ArtifactMapper artifactMapper;
private final ProjectFileService fileService; private final ProjectFileService fileService;
private final DocxValidator docxValidator; private final DocxValidator docxValidator;
/** /**
* 创建产物服务。 * 创建产物服务。
* *
* @param jdbc JDBC 客户端 * @param artifactMapper 产物 Mapper
* @param fileService 项目文件服务 * @param fileService 项目文件服务
* @param docxValidator DOCX 校验器 * @param docxValidator DOCX 校验器
*/ */
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) { public ArtifactService(
this.jdbc = jdbc; ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
this.artifactMapper = artifactMapper;
this.fileService = fileService; this.fileService = fileService;
this.docxValidator = docxValidator; this.docxValidator = docxValidator;
} }
@@ -118,36 +121,20 @@ public class ArtifactService {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空"); throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
} }
String hash = sha256(path); String hash = sha256(path);
UUID id = jdbc.sql(""" // “项目 + 路径”必须原子 upsert避免先查后写在并发重试时触发唯一约束竞态。
INSERT INTO app.artifact( ArtifactEntity entity = new ArtifactEntity();
id, project_id, run_id, kind, name, relative_path, mime_type, entity.setId(UUID.randomUUID());
size_bytes, sha256, metadata_json) entity.setProjectId(projectId);
VALUES (:id, :projectId, :runId, :kind, :name, :path, entity.setRunId(runId);
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', entity.setKind(kind);
:size, :sha256, CAST(:metadata AS jsonb)) entity.setName(name);
ON CONFLICT (project_id, relative_path) DO UPDATE SET entity.setRelativePath(relativePath);
run_id = EXCLUDED.run_id, entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
kind = EXCLUDED.kind, entity.setSizeBytes(size);
name = EXCLUDED.name, entity.setSha256(hash);
mime_type = EXCLUDED.mime_type, entity.setMetadataJson(metadata.toString());
size_bytes = EXCLUDED.size_bytes, ArtifactEntity stored = artifactMapper.upsert(entity);
sha256 = EXCLUDED.sha256, return toArtifactView(stored);
metadata_json = EXCLUDED.metadata_json,
published_at = CURRENT_TIMESTAMP
RETURNING id
""")
.param("id", UUID.randomUUID())
.param("projectId", projectId)
.param("runId", runId)
.param("kind", kind)
.param("name", name)
.param("path", relativePath)
.param("size", size)
.param("sha256", hash)
.param("metadata", metadata.toString())
.query(UUID.class)
.single();
return require(id);
} catch (IOException | NoSuchAlgorithmException exception) { } catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败"); throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
} }
@@ -175,10 +162,12 @@ public class ArtifactService {
* @return 按发布时间倒序的产物 * @return 按发布时间倒序的产物
*/ */
public List<ArtifactView> list(UUID projectId) { public List<ArtifactView> list(UUID projectId) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC") QueryWrapper query = artifactViewQuery()
.param("projectId", projectId) .where(ArtifactEntity::getProjectId).eq(projectId)
.query(ArtifactService::mapArtifact) .orderBy(ArtifactEntity::getPublishedAt).desc();
.list(); return artifactMapper.selectListByQuery(query).stream()
.map(ArtifactService::toArtifactView)
.toList();
} }
/** /**
@@ -187,21 +176,28 @@ public class ArtifactService {
* @param artifactId 产物 ID * @param artifactId 产物 ID
* @return 下载信息 * @return 下载信息
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public Download download(UUID artifactId) { public Download download(UUID artifactId) {
StoredArtifact artifact = jdbc.sql(""" QueryWrapper query = QueryWrapper.create()
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256 .select(
FROM app.artifact WHERE id = :id ArtifactEntity::getProjectId,
""") ArtifactEntity::getName,
.param("id", artifactId) ArtifactEntity::getRelativePath,
.query((rs, rowNum) -> new StoredArtifact( ArtifactEntity::getMimeType,
rs.getObject("project_id", UUID.class), ArtifactEntity::getSizeBytes,
rs.getString("name"), ArtifactEntity::getSha256)
rs.getString("relative_path"), .where(ArtifactEntity::getId).eq(artifactId);
rs.getString("mime_type"), ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
rs.getLong("size_bytes"), if (entity == null) {
rs.getString("sha256"))) throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在");
.optional() }
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在")); StoredArtifact artifact = new StoredArtifact(
entity.getProjectId(),
entity.getName(),
entity.getRelativePath(),
entity.getMimeType(),
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
entity.getSha256());
try { try {
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath()); Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
Resource resource = new UrlResource(path.toUri()); Resource resource = new UrlResource(path.toUri());
@@ -238,30 +234,44 @@ public class ArtifactService {
return HexFormat.of().formatHex(digest.digest()); return HexFormat.of().formatHex(digest.digest());
} }
private ArtifactView require(UUID id) { /**
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id") * 将产物实体转换成接口稳定视图。
.param("id", id) *
.query(ArtifactService::mapArtifact) * @param entity 产物实体
.optional() * @return 产物接口视图
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在")); */
} private static ArtifactView toArtifactView(ArtifactEntity entity) {
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ArtifactView( return new ArtifactView(
rs.getObject("id", UUID.class), entity.getId(),
rs.getObject("project_id", UUID.class), entity.getProjectId(),
rs.getObject("run_id", UUID.class), entity.getRunId(),
rs.getString("kind"), entity.getKind(),
rs.getString("name"), entity.getName(),
rs.getLong("size_bytes"), entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
rs.getString("metadata_json"), entity.getMetadataJson(),
rs.getObject("published_at", OffsetDateTime.class)); entity.getPublishedAt());
} }
private static final String ARTIFACT_SELECT = """ /**
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at * 构造产物接口列表使用的最小字段投影。
FROM app.artifact *
"""; * <p>该字段集合与迁移前 JDBC 列表 SQL 保持一致。下载路径、MIME 类型和 SHA-256
* 仅在下载场景读取,避免普通列表查询加载不参与响应的内部字段。</p>
*
* @return 只包含产物接口视图字段的查询构造器
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private static QueryWrapper artifactViewQuery() {
return QueryWrapper.create().select(
ArtifactEntity::getId,
ArtifactEntity::getProjectId,
ArtifactEntity::getRunId,
ArtifactEntity::getKind,
ArtifactEntity::getName,
ArtifactEntity::getSizeBytes,
ArtifactEntity::getMetadataJson,
ArtifactEntity::getPublishedAt);
}
/** /**
* 产物元数据。 * 产物元数据。

View File

@@ -1,19 +1,21 @@
package tech.easyflow.manuagent.auth; package tech.easyflow.manuagent.auth;
import tech.easyflow.manuagent.common.ApiException; import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.config.AppProperties;
import java.util.UUID; import java.util.UUID;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.core.annotation.Order; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.AppUserEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper;
/** /**
* 管理单管理员账户和当前用户标识。 * 管理单管理员账户和当前用户标识。
@@ -22,19 +24,19 @@ import org.springframework.core.annotation.Order;
@Order(1) @Order(1)
public class UserService implements UserDetailsService, ApplicationRunner { public class UserService implements UserDetailsService, ApplicationRunner {
private final JdbcClient jdbc; private final AppUserMapper userMapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AppProperties properties; private final AppProperties properties;
/** /**
* 创建用户服务。 * 创建用户服务。
* *
* @param jdbc JDBC 客户端 * @param userMapper 用户表 Mapper
* @param passwordEncoder 密码编码器 * @param passwordEncoder 密码编码器
* @param properties 应用配置 * @param properties 应用配置
*/ */
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) { public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
this.jdbc = jdbc; this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.properties = properties; this.properties = properties;
} }
@@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner {
*/ */
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single(); long count = userMapper.selectCountByQuery(QueryWrapper.create());
if (count == 0) { if (count > 0) {
jdbc.sql(""" return;
INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, :password, :displayName)
""")
.param("id", UUID.randomUUID())
.param("username", properties.adminUsername())
.param("password", passwordEncoder.encode(properties.adminPassword()))
.param("displayName", "管理员")
.update();
} }
// 首次启动时仍由应用层生成 UUIDselective insert 让 enabled 和时间字段沿用数据库默认值。
AppUserEntity administrator = new AppUserEntity();
administrator.setId(UUID.randomUUID());
administrator.setUsername(properties.adminUsername());
administrator.setPasswordHash(passwordEncoder.encode(properties.adminPassword()));
administrator.setDisplayName("管理员");
userMapper.insertSelective(administrator);
} }
/** /**
@@ -68,16 +70,23 @@ public class UserService implements UserDetailsService, ApplicationRunner {
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出 * @throws UsernameNotFoundException 用户不存在或被禁用时抛出
*/ */
@Override @Override
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username") QueryWrapper query = QueryWrapper.create()
.param("username", username) .select(
.query((rs, rowNum) -> User.withUsername(rs.getString("username")) AppUserEntity::getUsername,
.password(rs.getString("password_hash")) AppUserEntity::getPasswordHash,
.roles("ADMIN") AppUserEntity::getEnabled)
.disabled(!rs.getBoolean("enabled")) .where(AppUserEntity::getUsername).eq(username);
.build()) AppUserEntity entity = userMapper.selectOneByQuery(query);
.optional() if (entity == null) {
.orElseThrow(() -> new UsernameNotFoundException("账户不存在")); throw new UsernameNotFoundException("账户不存在");
}
return User.withUsername(entity.getUsername())
.password(entity.getPasswordHash())
.roles("ADMIN")
.disabled(!Boolean.TRUE.equals(entity.getEnabled()))
.build();
} }
/** /**
@@ -87,11 +96,15 @@ public class UserService implements UserDetailsService, ApplicationRunner {
* @return 用户 UUID * @return 用户 UUID
* @throws ApiException 用户不存在时抛出 * @throws ApiException 用户不存在时抛出
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public UUID requireUserId(String username) { public UUID requireUserId(String username) {
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username") QueryWrapper query = QueryWrapper.create()
.param("username", username) .select(AppUserEntity::getId)
.query(UUID.class) .where(AppUserEntity::getUsername).eq(username);
.optional() AppUserEntity entity = userMapper.selectOneByQuery(query);
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在")); if (entity == null) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在");
}
return entity.getId();
} }
} }

View File

@@ -8,14 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* 应用自身的运行配置。 * 应用自身的运行配置。
* *
* @param dataRoot 项目材料、工作区和产物根目录 * @param dataRoot 项目材料、工作区和产物根目录
* @param deepseekKeyFile DeepSeek Key 文件
* @param dashscopeKeyFile 百炼 Key 文件 * @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥 * @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名 * @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码 * @param adminPassword 本地管理员初始密码
* @param modelBaseUrl 默认模型端点
* @param modelId 默认模型标识
* @param modelContextWindow 默认模型上下文窗口
* @param sandboxImage Agent Docker 运行镜像 * @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络 * @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时 * @param runTimeout 单次 Agent 运行超时
@@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app") @ConfigurationProperties(prefix = "app")
public record AppProperties( public record AppProperties(
Path dataRoot, Path dataRoot,
Path deepseekKeyFile,
Path dashscopeKeyFile, Path dashscopeKeyFile,
String masterKey, String masterKey,
String adminUsername, String adminUsername,
String adminPassword, String adminPassword,
String modelBaseUrl,
String modelId,
int modelContextWindow,
String sandboxImage, String sandboxImage,
String sandboxNetwork, String sandboxNetwork,
Duration runTimeout) { Duration runTimeout) {

View File

@@ -0,0 +1,16 @@
package tech.easyflow.manuagent.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* 配置 MyBatis-Flex Mapper 扫描。
*
* <p>业务 Mapper 统一放在 {@code tech.easyflow.manuagent.mapper} 包中。数据库连接、连接池和
* Spring 事务管理器继续复用 Spring Boot 已配置的数据源,使 MyBatis-Flex 的 Mapper 调用、
* 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务。</p>
*/
@Configuration
@MapperScan("tech.easyflow.manuagent.mapper")
public class MyBatisFlexConfiguration {
}

View File

@@ -0,0 +1,55 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
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;
/**
* 映射 {@code app.agent_event} 表的持久化 Agent 事件。
*
* <p>事件 ID 由 PostgreSQL 的 BIGSERIAL 序列生成,业务代码通过
* {@code INSERT ... RETURNING} 原子取得该 ID确保游标回放顺序与数据库提交顺序一致。</p>
*/
@Table(value = "agent_event", schema = "app")
public class AgentEventEntity {
/** PostgreSQL 全局递增事件序号。 */
@Id(keyType = KeyType.Auto)
private Long id;
/** 事件所属项目。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID projectId;
/** 事件所属 Agent Run。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID runId;
/** AG-UI 事件类型。 */
private String eventType;
/** 用于外部追踪和去重的随机事件标识。 */
private String eventId;
/** JSONB 格式的事件负载。 */
@Column(value = "payload", jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String payloadJson;
/** 数据库记录的事件创建时间。 */
private OffsetDateTime createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public UUID getProjectId() { return projectId; }
public void setProjectId(UUID projectId) { this.projectId = projectId; }
public UUID getRunId() { return runId; }
public void setRunId(UUID runId) { this.runId = runId; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getPayloadJson() { return payloadJson; }
public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,86 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
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;
/**
* 映射 {@code app.agent_run} 表的 Agent 运行状态实体。
*
* <p>运行终态切换并不依赖 BaseMapper 的无条件更新,而由 Mapper XML 使用
* {@code WHERE status = 'RUNNING'} 实现乐观状态机,避免停止、完成和失败并发覆盖。</p>
*/
@Table(value = "agent_run", schema = "app")
public class AgentRunEntity {
/** Run 主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 所属项目。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID projectId;
/** 恢复、重试场景下关联的父 Run。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID parentRunId;
/** 本次 Run 固定使用的模型配置。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID modelConfigId;
/** INITIAL、RESUME 或 RETRY。 */
private String triggerType;
/** 当前运行状态。 */
private String status;
/** 等待用户确认时保存的 Ask JSON。 */
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String pendingInterrupt;
/** 跨日志追踪标识。 */
private String traceId;
/** 失败或中断错误码。 */
private String errorCode;
/** 面向用户的失败信息。 */
private String errorMessage;
/** Run 开始时间。 */
private OffsetDateTime startedAt;
/** 进入终态或等待态的时间。 */
private OffsetDateTime endedAt;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 最后更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getProjectId() { return projectId; }
public void setProjectId(UUID projectId) { this.projectId = projectId; }
public UUID getParentRunId() { return parentRunId; }
public void setParentRunId(UUID parentRunId) { this.parentRunId = parentRunId; }
public UUID getModelConfigId() { return modelConfigId; }
public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; }
public String getTriggerType() { return triggerType; }
public void setTriggerType(String triggerType) { this.triggerType = triggerType; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getPendingInterrupt() { return pendingInterrupt; }
public void setPendingInterrupt(String pendingInterrupt) { this.pendingInterrupt = pendingInterrupt; }
public String getTraceId() { return traceId; }
public void setTraceId(String traceId) { this.traceId = traceId; }
public String getErrorCode() { return errorCode; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public OffsetDateTime getStartedAt() { return startedAt; }
public void setStartedAt(OffsetDateTime startedAt) { this.startedAt = startedAt; }
public OffsetDateTime getEndedAt() { return endedAt; }
public void setEndedAt(OffsetDateTime endedAt) { this.endedAt = endedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,112 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
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;
/**
* 映射 {@code app.app_user} 表的管理员用户实体。
*
* <p>该实体只服务于数据库持久化,不直接作为 HTTP 接口的输入或输出。UUID 主键继续由应用层生成,
* MyBatis-Flex 的 UUID 生成器只会在主键为空时补值,因此既能保留调用方已生成的 UUID也能避免
* 遗漏主键造成数据库约束错误。写入时使用 selective insert 保留已有 UUID
* 其余未赋值字段仍由数据库默认值负责填充。</p>
*/
@Table(value = "app_user", schema = "app")
public class AppUserEntity {
/** 用户主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 登录用户名。 */
private String username;
/** Spring Security 使用的密码摘要。 */
private String passwordHash;
/** 页面展示名称。 */
private String displayName;
/** 账户是否允许登录。 */
private Boolean enabled;
/** 最近一次登录时间。 */
private OffsetDateTime lastLoginAt;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public OffsetDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(OffsetDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
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;
/**
* 映射 {@code app.artifact} 表的最终产物实体。
*/
@Table(value = "artifact", schema = "app")
public class ArtifactEntity {
/** 产物主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 所属项目。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID projectId;
/** 生成该产物的 Agent Run。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID runId;
/** 产物业务类型。 */
private String kind;
/** 下载文件名。 */
private String name;
/** 相对于项目根目录的受控路径。 */
private String relativePath;
/** 文件 MIME 类型。 */
private String mimeType;
/** 文件字节数。 */
private Long sizeBytes;
/** 文件内容 SHA-256。 */
private String sha256;
/** 业务元数据 JSON。 */
@Column(jdbcType = org.apache.ibatis.type.JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String metadataJson;
/** 最近发布时间。 */
private OffsetDateTime publishedAt;
/** 首次创建时间。 */
private OffsetDateTime createdAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getProjectId() { return projectId; }
public void setProjectId(UUID projectId) { this.projectId = projectId; }
public UUID getRunId() { return runId; }
public void setRunId(UUID runId) { this.runId = runId; }
public String getKind() { return kind; }
public void setKind(String kind) { this.kind = kind; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getRelativePath() { return relativePath; }
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
public String getMimeType() { return mimeType; }
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
public Long getSizeBytes() { return sizeBytes; }
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
public String getSha256() { return sha256; }
public void setSha256(String sha256) { this.sha256 = sha256; }
public String getMetadataJson() { return metadataJson; }
public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; }
public OffsetDateTime getPublishedAt() { return publishedAt; }
public void setPublishedAt(OffsetDateTime publishedAt) { this.publishedAt = publishedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,37 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体。
*/
@Table(value = "model_assignment", schema = "app")
public class ModelAssignmentEntity {
/** Agent 角色,同时也是业务主键。 */
@Id(keyType = KeyType.None)
private String role;
/** 分配的模型配置。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID modelConfigId;
/** 执行分配的用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID assignedBy;
/** 最近更新时间。 */
private OffsetDateTime updatedAt;
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public UUID getModelConfigId() { return modelConfigId; }
public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; }
public UUID getAssignedBy() { return assignedBy; }
public void setAssignedBy(UUID assignedBy) { this.assignedBy = assignedBy; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,89 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
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;
/**
* 映射 {@code app.model_config} 表的模型配置与加密密钥实体。
*
* <p>密钥字段始终保存 AES-GCM 密文;实体只在服务内部使用,严禁直接作为接口响应。</p>
*/
@Table(value = "model_config", schema = "app")
public class ModelConfigEntity {
/** 模型配置主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 配置名称。 */
private String name;
/** 模型服务商类型。 */
private String provider;
/** OpenAI 兼容 API 根地址。 */
private String baseUrl;
/** 上游模型标识。 */
private String modelId;
/** AES-GCM 加密后的 API Key。 */
private byte[] apiKeyCiphertext;
/** 仅用于界面展示的密钥尾号。 */
private String apiKeyHint;
/** 密钥加密格式版本。 */
private Short keyVersion;
/** 高级请求配置 JSON。 */
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String configJson;
/** 模型能力 JSON。 */
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String capabilitiesJson;
/** 是否启用。 */
private Boolean enabled;
/** 是否为全局默认模型。 */
@Column("is_default")
private Boolean defaultModel;
/** 创建用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID createdBy;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getModelId() { return modelId; }
public void setModelId(String modelId) { this.modelId = modelId; }
public byte[] getApiKeyCiphertext() { return apiKeyCiphertext; }
public void setApiKeyCiphertext(byte[] apiKeyCiphertext) { this.apiKeyCiphertext = apiKeyCiphertext; }
public String getApiKeyHint() { return apiKeyHint; }
public void setApiKeyHint(String apiKeyHint) { this.apiKeyHint = apiKeyHint; }
public Short getKeyVersion() { return keyVersion; }
public void setKeyVersion(Short keyVersion) { this.keyVersion = keyVersion; }
public String getConfigJson() { return configJson; }
public void setConfigJson(String configJson) { this.configJson = configJson; }
public String getCapabilitiesJson() { return capabilitiesJson; }
public void setCapabilitiesJson(String capabilitiesJson) { this.capabilitiesJson = capabilitiesJson; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Boolean getDefaultModel() { return defaultModel; }
public void setDefaultModel(Boolean defaultModel) { this.defaultModel = defaultModel; }
public UUID getCreatedBy() { return createdBy; }
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,62 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
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;
/**
* 映射 {@code app.project} 表的企业申报项目实体。
*/
@Table(value = "project", schema = "app")
public class ProjectEntity {
/** 项目主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 企业名称。 */
private String companyName;
/** 项目显示名称。 */
private String projectName;
/** AgentScope/AG-UI 使用的线程标识。 */
private String aguiThreadId;
/** 申报等级。 */
private String applicationLevel;
/** 当前业务阶段。 */
private String status;
/** 创建用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID createdBy;
/** 业务版本号。 */
private Long version;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getCompanyName() { return companyName; }
public void setCompanyName(String companyName) { this.companyName = companyName; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getAguiThreadId() { return aguiThreadId; }
public void setAguiThreadId(String aguiThreadId) { this.aguiThreadId = aguiThreadId; }
public String getApplicationLevel() { return applicationLevel; }
public void setApplicationLevel(String applicationLevel) { this.applicationLevel = applicationLevel; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public UUID getCreatedBy() { return createdBy; }
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,82 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
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;
/**
* 映射 {@code app.project_file} 表的项目材料实体。
*
* <p>实体保存文件的受控相对路径、完整性摘要及软删除状态;真实文件仍由
* {@code ProjectFileService} 在项目工作区内管理。</p>
*/
@Table(value = "project_file", schema = "app")
public class ProjectFileEntity {
/** 文件主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 所属项目主键。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID projectId;
/** 用户上传时的原始文件名。 */
private String originalName;
/** 工作区内实际保存的文件名。 */
private String storedName;
/** 相对于项目根目录的受控路径。 */
private String relativePath;
/** 内容检测得到的 MIME 类型。 */
private String mimeType;
/** 小写文件扩展名。 */
private String extension;
/** 文件字节数。 */
private Long sizeBytes;
/** 文件内容 SHA-256。 */
private String sha256;
/** 材料处理状态。 */
private String status;
/** 上传用户主键。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID uploadedBy;
/** 软删除时间,空值表示有效。 */
private OffsetDateTime deletedAt;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getProjectId() { return projectId; }
public void setProjectId(UUID projectId) { this.projectId = projectId; }
public String getOriginalName() { return originalName; }
public void setOriginalName(String originalName) { this.originalName = originalName; }
public String getStoredName() { return storedName; }
public void setStoredName(String storedName) { this.storedName = storedName; }
public String getRelativePath() { return relativePath; }
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
public String getMimeType() { return mimeType; }
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
public String getExtension() { return extension; }
public void setExtension(String extension) { this.extension = extension; }
public Long getSizeBytes() { return sizeBytes; }
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
public String getSha256() { return sha256; }
public void setSha256(String sha256) { this.sha256 = sha256; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public OffsetDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,67 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
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;
/**
* 映射 {@code app.project_plan} 表的不可变规划版本实体。
*/
@Table(value = "project_plan", schema = "app")
public class ProjectPlanEntity {
/** 规划主键。 */
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
@Column(typeHandler = UuidTypeHandler.class)
private UUID id;
/** 所属项目。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID projectId;
/** 项目内递增版本号。 */
private Integer planVersion;
/** 草稿、已确认或已取代状态。 */
private String status;
/** 完整规划 JSON。 */
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
private String planJson;
/** 创建用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID createdBy;
/** 确认用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID confirmedBy;
/** 确认时间。 */
private OffsetDateTime confirmedAt;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getProjectId() { return projectId; }
public void setProjectId(UUID projectId) { this.projectId = projectId; }
public Integer getPlanVersion() { return planVersion; }
public void setPlanVersion(Integer planVersion) { this.planVersion = planVersion; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getPlanJson() { return planJson; }
public void setPlanJson(String planJson) { this.planJson = planJson; }
public UUID getCreatedBy() { return createdBy; }
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
public UUID getConfirmedBy() { return confirmedBy; }
public void setConfirmedBy(UUID confirmedBy) { this.confirmedBy = confirmedBy; }
public OffsetDateTime getConfirmedAt() { return confirmedAt; }
public void setConfirmedAt(OffsetDateTime confirmedAt) { this.confirmedAt = confirmedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.manuagent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
/**
* 映射应用自管的 {@code app.skill_config} 表。
*
* <p>Skill 正文和资源不属于该实体,它们继续由 AgentScope 的 PostgreSQL repository 管理。</p>
*/
@Table(value = "skill_config", schema = "app")
public class SkillConfigEntity {
/** Skill 标准名称,同时引用 AgentScope Skill 主表。 */
@Id(keyType = KeyType.None)
private String skillName;
/** Skill 包版本。 */
private String version;
/** BUILTIN 或 IMPORTED。 */
private String sourceType;
/** 是否允许 Agent 使用。 */
private Boolean enabled;
/** 是否禁止从界面编辑内容。 */
private Boolean readOnly;
/** Skill 包内容摘要。 */
private String checksum;
/** VALID 或 INVALID。 */
private String validationStatus;
/** 校验失败说明。 */
private String validationMessage;
/** 导入用户。 */
@Column(typeHandler = UuidTypeHandler.class)
private UUID importedBy;
/** 创建时间。 */
private OffsetDateTime createdAt;
/** 更新时间。 */
private OffsetDateTime updatedAt;
public String getSkillName() { return skillName; }
public void setSkillName(String skillName) { this.skillName = skillName; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Boolean getReadOnly() { return readOnly; }
public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
public String getChecksum() { return checksum; }
public void setChecksum(String checksum) { this.checksum = checksum; }
public String getValidationStatus() { return validationStatus; }
public void setValidationStatus(String validationStatus) { this.validationStatus = validationStatus; }
public String getValidationMessage() { return validationMessage; }
public void setValidationMessage(String validationMessage) { this.validationMessage = validationMessage; }
public UUID getImportedBy() { return importedBy; }
public void setImportedBy(UUID importedBy) { this.importedBy = importedBy; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,26 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentEventEntity;
/**
* 提供 Agent 事件写入、游标回放及运行恢复所需的持久化能力。
*/
public interface AgentEventMapper extends BaseMapper<AgentEventEntity> {
/**
* 插入事件并原子返回数据库生成的 BIGSERIAL 序号及创建时间。
*
* @param event 待写入事件
* @return 已持久化的完整事件
*/
AgentEventEntity insertReturning(@Param("event") AgentEventEntity event);
/** 查询指定 Run 最近一次 RUN_STARTED 事件中的业务阶段。 */
String selectLatestStartedPhase(@Param("runId") UUID runId);
/** 查询项目最近一次包含材料决策数组的 ASK_RESPONDED 事件负载。 */
String selectLatestMaterialResponseJson(@Param("projectId") UUID projectId);
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentRunEntity;
/**
* 提供 Agent Run 查询以及带前置状态条件的原子状态迁移。
*/
public interface AgentRunMapper extends BaseMapper<AgentRunEntity> {
/** 将等待输入的 Run 标记为已完成。 */
int completeWaiting(@Param("runId") UUID runId);
/** 将运行中的 Run 标记为用户中断。 */
int interruptRunning(@Param("runId") UUID runId);
/** 将运行中的 Run 切换为等待输入并保存 Ask。 */
int waitForInput(@Param("runId") UUID runId, @Param("interruptJson") String interruptJson);
/** 将运行中的 Run 标记为成功完成。 */
int completeRunning(@Param("runId") UUID runId);
/** 将运行中的 Run 标记为失败。 */
int failRunning(@Param("runId") UUID runId, @Param("message") String message);
/** 启动恢复时将所有遗留 RUNNING 状态标记为进程重启中断。 */
int interruptRunningAfterRestart();
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.AppUserEntity;
/**
* 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力。
*
* <p>用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper
* 不额外维护 Mapper XML。</p>
*/
public interface AppUserMapper extends BaseMapper<AppUserEntity> {
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ArtifactEntity;
/**
* 提供产物基础查询以及 PostgreSQL 原子 upsert 能力。
*/
public interface ArtifactMapper extends BaseMapper<ArtifactEntity> {
/**
* 按“项目 + 相对路径”插入或更新产物,并返回数据库中的完整记录。
*
* @param artifact 待发布产物
* @return 插入或更新后的产物记录
*/
ArtifactEntity upsert(@Param("artifact") ArtifactEntity artifact);
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
/**
* 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert。
*/
public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity> {
/** 按角色插入或更新模型分配。 */
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
/** 删除指定模型遗留的角色分配。 */
int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId);
}

View File

@@ -0,0 +1,51 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
/**
* 提供模型配置的 MyBatis-Flex CRUD 能力。
*
* <p>普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新
* 使用 XML 显式声明 TypeHandler避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。</p>
*/
public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
/**
* 新增模型配置,并显式按 JSONB 类型绑定高级配置与能力声明。
*
* @param model 待新增模型实体
* @return 受影响行数
*/
int insertModel(@Param("model") ModelConfigEntity model);
/**
* 更新模型可编辑字段;实体未携带新密钥时保留数据库中的原密钥。
*
* @param model 待更新模型实体
* @return 受影响行数
*/
int updateModel(@Param("model") ModelConfigEntity model);
/**
* 清除当前默认模型标记,保持与迁移前 JDBC SQL 相同的更新范围。
*
* @return 受影响行数
*/
int clearDefault();
/**
* 将指定模型设为默认模型,并由数据库生成更新时间。
*
* @param id 模型主键
* @return 受影响行数
*/
int setDefault(@Param("id") java.util.UUID id);
/** 按主键更新模型启用状态,并刷新数据库更新时间。 */
int setEnabled(@Param("id") java.util.UUID id, @Param("enabled") boolean enabled);
/** 删除不再被 Run 或角色分配引用的模型。 */
int deleteModel(@Param("id") java.util.UUID id);
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
/**
* 提供 {@code app.project_file} 表的单表持久化能力。
*/
public interface ProjectFileMapper extends BaseMapper<ProjectFileEntity> {
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ProjectEntity;
/**
* 提供项目基础 CRUD、阶段更新和项目级联清理所需的显式 SQL 接口。
*/
public interface ProjectMapper extends BaseMapper<ProjectEntity> {
/** 判断项目是否仍有运行中的 Agent。 */
boolean hasRunningRun(@Param("projectId") UUID projectId);
/** 删除项目事件。 */
int deleteEvents(@Param("projectId") UUID projectId);
/** 删除项目产物。 */
int deleteArtifacts(@Param("projectId") UUID projectId);
/** 删除项目规划。 */
int deletePlans(@Param("projectId") UUID projectId);
/** 删除项目材料元数据。 */
int deleteFiles(@Param("projectId") UUID projectId);
/** 删除项目运行记录。 */
int deleteRuns(@Param("projectId") UUID projectId);
/** 原子更新项目阶段并递增版本。 */
int updateStatus(@Param("projectId") UUID projectId, @Param("status") String status);
}

View File

@@ -0,0 +1,25 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
/**
* 提供规划版本查询以及带条件的草稿写入、确认能力。
*/
public interface ProjectPlanMapper extends BaseMapper<ProjectPlanEntity> {
/** 插入项目下一版草稿并返回完整记录。 */
ProjectPlanEntity insertNextDraft(@Param("plan") ProjectPlanEntity plan);
/** 按“已确认优先、版本倒序”读取项目当前规划。 */
ProjectPlanEntity selectCurrent(@Param("projectId") UUID projectId);
/** 仅将仍处于 DRAFT 的指定版本确认,并返回确认后的记录。 */
ProjectPlanEntity confirmDraft(
@Param("projectId") UUID projectId,
@Param("planId") UUID planId,
@Param("planJson") String planJson,
@Param("userId") UUID userId);
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.manuagent.mapper;
import com.mybatisflex.core.BaseMapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.SkillConfigEntity;
/**
* 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查。
*/
public interface SkillConfigMapper extends BaseMapper<SkillConfigEntity> {
/** 列出全部 Skill 联合视图。 */
List<SkillViewRow> selectViews();
/** 按名称读取一个 Skill 联合视图。 */
SkillViewRow selectView(@Param("name") String name);
/**
* 按迁移前 SQL 的条件更新 Skill 启用状态,并由数据库生成更新时间。
*
* @param name Skill 名称
* @param enabled 是否启用
* @return 受影响行数
*/
int updateEnabled(@Param("name") String name, @Param("enabled") boolean enabled);
}

View File

@@ -0,0 +1,38 @@
package tech.easyflow.manuagent.mapper;
import java.time.OffsetDateTime;
/**
* 承载 AgentScope Skill 主表与应用 Skill 配置表只读联查结果。
*/
public class SkillViewRow {
private String name;
private String description;
private String version;
private String sourceType;
private Boolean enabled;
private Boolean readOnly;
private String validationStatus;
private String validationMessage;
private OffsetDateTime updatedAt;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Boolean getReadOnly() { return readOnly; }
public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
public String getValidationStatus() { return validationStatus; }
public void setValidationStatus(String validationStatus) { this.validationStatus = validationStatus; }
public String getValidationMessage() { return validationMessage; }
public void setValidationMessage(String validationMessage) { this.validationMessage = validationMessage; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -1,11 +1,14 @@
package tech.easyflow.manuagent.model; package tech.easyflow.manuagent.model;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@@ -72,7 +75,25 @@ public class ModelController {
} }
/** /**
* 测试模型连接 * 使用管理页面当前草稿测试模型连接,但不保存草稿内容
*
* <p>已有模型且 API 地址未变化时允许不提交 API Key此时服务层只读取该模型已加密保存的密钥
* 新模型或修改 API 地址后的草稿必须提交 API Key避免把隐藏密钥转发到其他主机。</p>
*
* @param input 当前表单中的连接测试输入
* @return 测试结果
*/
@PostMapping("/test")
public ModelService.ConnectionResult testDraft(
@Valid @RequestBody ModelService.ConnectionTestInput input) {
return modelService.test(input);
}
/**
* 使用数据库中已保存的完整配置测试模型连接。
*
* <p>保留该接口以兼容已有调用方;管理页面使用 {@code POST /api/models/test}
* 测试未保存草稿。</p>
* *
* @param id 模型 ID * @param id 模型 ID
* @return 测试结果 * @return 测试结果
@@ -93,4 +114,37 @@ public class ModelController {
public void setDefault(@PathVariable UUID id, Principal principal) { public void setDefault(@PathVariable UUID id, Principal principal) {
modelService.setDefault(id, principal); modelService.setDefault(id, principal);
} }
/**
* 启用或停用模型。
*
* @param id 模型 ID
* @param input 状态输入
* @return 更新后的模型
*/
@PatchMapping("/{id}/enabled")
public ModelService.ModelView setEnabled(
@PathVariable UUID id,
@Valid @RequestBody EnabledInput input) {
return modelService.setEnabled(id, input.enabled());
}
/**
* 删除从未被历史 Run 引用的非默认模型。
*
* @param id 模型 ID
*/
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
modelService.delete(id);
}
/**
* 模型启用状态输入。
*
* @param enabled 是否启用
*/
public record EnabledInput(@NotNull Boolean enabled) {
}
} }

View File

@@ -1,28 +1,31 @@
package tech.easyflow.manuagent.model; package tech.easyflow.manuagent.model;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; 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 com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal; import java.security.Principal;
import java.time.Duration; import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments; import jakarta.validation.constraints.Size;
import org.springframework.boot.ApplicationRunner; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -30,89 +33,70 @@ import org.springframework.transaction.annotation.Transactional;
* 管理 OpenAI 兼容模型配置、密钥和连接测试。 * 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/ */
@Service @Service
@Order(2) public class ModelService {
public class ModelService implements ApplicationRunner {
private final JdbcClient jdbc; private final ModelConfigMapper modelMapper;
private final ModelAssignmentMapper assignmentMapper;
private final AgentRunMapper runMapper;
private final UserService userService; private final UserService userService;
private final KeyCipher keyCipher; private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final HttpClient httpClient; private final HttpClient httpClient;
/** /**
* 创建模型服务。 * 创建模型服务。
* *
* @param jdbc JDBC 客户端 * @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper
* @param userService 用户服务 * @param userService 用户服务
* @param keyCipher 密钥加密器 * @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
@Autowired
public ModelService( public ModelService(
JdbcClient jdbc, ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService, UserService userService,
KeyCipher keyCipher, KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.jdbc = jdbc; this(
this.userService = userService; modelMapper,
this.keyCipher = keyCipher; assignmentMapper,
this.properties = properties; runMapper,
this.objectMapper = objectMapper; userService,
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); keyCipher,
objectMapper,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
} }
/** /**
* 从项目根目录 Key 文件初始化默认模型 * 创建可替换 HTTP 客户端的模型服务,仅供同包测试隔离外部网络边界
* *
* @param args 启动参数 * @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param objectMapper JSON 映射器
* @param httpClient 模型连接使用的 HTTP 客户端
*/ */
@Override ModelService(
@Transactional ModelConfigMapper modelMapper,
public void run(ApplicationArguments args) { ModelAssignmentMapper assignmentMapper,
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single(); AgentRunMapper runMapper,
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) { UserService userService,
return; KeyCipher keyCipher,
} ObjectMapper objectMapper,
try { HttpClient httpClient) {
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim(); this.modelMapper = modelMapper;
if (key.isBlank()) { this.assignmentMapper = assignmentMapper;
return; this.runMapper = runMapper;
} this.userService = userService;
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1") this.keyCipher = keyCipher;
.query(UUID.class) this.objectMapper = objectMapper;
.single(); this.httpClient = httpClient;
UUID modelId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, is_default, created_by)
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
""")
.param("id", modelId)
.param("baseUrl", properties.modelBaseUrl())
.param("modelId", properties.modelId())
.param("ciphertext", keyCipher.encrypt(key))
.param("hint", hint(key))
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
.param("capabilities", json(Map.of(
"toolCalling", true,
"reasoning", true,
"contextWindow", properties.modelContextWindow())))
.param("userId", adminId)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
.param("role", role)
.param("id", modelId)
.param("userId", adminId)
.update();
}
} catch (IOException exception) {
throw new IllegalStateException("无法读取默认模型 Key", exception);
}
} }
/** /**
@@ -121,9 +105,12 @@ public class ModelService implements ApplicationRunner {
* @return 模型列表 * @return 模型列表
*/ */
public List<ModelView> list() { public List<ModelView> list() {
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC") QueryWrapper query = modelViewQuery()
.query(ModelService::mapModel) .orderBy(ModelConfigEntity::getDefaultModel).desc()
.list(); .orderBy(ModelConfigEntity::getUpdatedAt).desc();
return modelMapper.selectListByQuery(query).stream()
.map(ModelService::toModelView)
.toList();
} }
/** /**
@@ -142,57 +129,31 @@ public class ModelService implements ApplicationRunner {
if (input.apiKey() == null || input.apiKey().isBlank()) { if (input.apiKey() == null || input.apiKey().isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key"); throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
} }
QueryWrapper defaultQuery = QueryWrapper.create()
.where(ModelConfigEntity::getDefaultModel).eq(true);
boolean firstDefault = modelMapper.selectCountByQuery(defaultQuery) == 0;
id = UUID.randomUUID(); id = UUID.randomUUID();
jdbc.sql(""" ModelConfigEntity model = editableModel(id, input);
INSERT INTO app.model_config( model.setProvider("OPENAI_COMPATIBLE");
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint, model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
key_version, config_json, capabilities_json, created_by) model.setApiKeyHint(hint(input.apiKey().trim()));
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext, model.setKeyVersion((short) 1);
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId) model.setDefaultModel(firstDefault);
""") model.setCreatedBy(userId);
.param("id", id) modelMapper.insertModel(model);
.param("name", input.name().trim()) if (firstDefault) {
.param("baseUrl", normalizeBaseUrl(input.baseUrl())) for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
.param("modelId", input.modelId().trim()) upsertAssignment(role, id, userId);
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim())) }
.param("hint", hint(input.apiKey().trim())) }
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("userId", userId)
.update();
} else { } else {
int updated = input.apiKey() == null || input.apiKey().isBlank() ModelConfigEntity model = editableModel(id, input);
? jdbc.sql(""" if (input.apiKey() != null && !input.apiKey().isBlank()) {
UPDATE app.model_config model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
SET name = :name, base_url = :baseUrl, model_id = :modelId, model.setApiKeyHint(hint(input.apiKey().trim()));
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb), model.setKeyVersion((short) 1);
updated_at = CURRENT_TIMESTAMP }
WHERE id = :id int updated = modelMapper.updateModel(model);
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update()
: jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update();
if (updated != 1) { if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
} }
@@ -208,25 +169,64 @@ public class ModelService implements ApplicationRunner {
*/ */
@Transactional @Transactional
public void setDefault(UUID id, Principal principal) { public void setDefault(UUID id, Principal principal) {
require(id); ModelView target = require(id);
if (!target.enabled()) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
}
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update(); modelMapper.clearDefault();
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id") if (modelMapper.setDefault(id) != 1) {
.param("id", id) throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
.update(); }
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql(""" upsertAssignment(role, id, userId);
INSERT INTO app.model_assignment(role, model_config_id, assigned_by) }
VALUES (:role, :id, :userId) }
ON CONFLICT (role) DO UPDATE
SET model_config_id = EXCLUDED.model_config_id, /**
assigned_by = EXCLUDED.assigned_by, * 更新模型启用状态。
updated_at = CURRENT_TIMESTAMP *
""") * <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
.param("role", role) * 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
.param("id", id) *
.param("userId", userId) * @param id 模型 ID
.update(); * @param enabled 新启用状态
* @return 更新后的安全模型视图
*/
@Transactional
public ModelView setEnabled(UUID id, boolean enabled) {
ModelView current = require(id);
if (!enabled && current.defaultModel()) {
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "请先设置新的默认模型");
}
if (!enabled && countRuns(id, true) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_IN_USE", "模型正在被运行中的任务使用");
}
if (modelMapper.setEnabled(id, enabled) != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
return require(id);
}
/**
* 真删除从未被历史 Run 使用的非默认模型。
*
* <p>历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。</p>
*
* @param id 模型 ID
*/
@Transactional
public void delete(UUID id) {
ModelView current = require(id);
if (current.defaultModel()) {
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "默认模型不能删除");
}
if (countRuns(id, false) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_HISTORY_EXISTS", "模型已有任务记录,请改为停用");
}
assignmentMapper.deleteByModelConfigId(id);
if (modelMapper.deleteModel(id) != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
} }
} }
@@ -237,7 +237,40 @@ public class ModelService implements ApplicationRunner {
* @return 测试结果 * @return 测试结果
*/ */
public ConnectionResult test(UUID id) { public ConnectionResult test(UUID id) {
ModelSecret model = requireSecret(id); return testConnection(requireRuntimeModel(id));
}
/**
* 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。
*
* <p>编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key
* 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。</p>
*
* @param input 当前模型连接草稿
* @return 测试结果
*/
public ConnectionResult test(ConnectionTestInput input) {
String baseUrl = normalizeBaseUrl(input.baseUrl());
String apiKey = input.apiKey() == null ? "" : input.apiKey().trim();
if (apiKey.isBlank()) {
apiKey = storedApiKey(input.id(), baseUrl);
}
ModelSecret draft = new ModelSecret(
input.id(),
baseUrl,
input.modelId().trim(),
apiKey,
0);
return testConnection(draft);
}
/**
* 向 OpenAI 兼容接口发送最小 Chat Completion 请求。
*
* @param model 已解析出明文密钥的临时连接配置
* @return 测试结果
*/
private ConnectionResult testConnection(ModelSecret model) {
String requestJson = json(Map.of( String requestJson = json(Map.of(
"model", model.modelId(), "model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")), "messages", List.of(Map.of("role", "user", "content", "回复 OK")),
@@ -272,36 +305,59 @@ public class ModelService implements ApplicationRunner {
* *
* @return 默认模型机密配置 * @return 默认模型机密配置
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public ModelSecret defaultModelSecret() { public ModelSecret defaultModelSecret() {
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled") QueryWrapper query = QueryWrapper.create()
.query(UUID.class) .select(
.optional() ModelConfigEntity::getId,
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型")); ModelConfigEntity::getBaseUrl,
return requireSecret(id); ModelConfigEntity::getModelId,
ModelConfigEntity::getApiKeyCiphertext,
ModelConfigEntity::getCapabilitiesJson)
.where(ModelConfigEntity::getDefaultModel).eq(true)
.and(ModelConfigEntity::getEnabled).eq(true);
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
if (model == null) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型");
}
return toModelSecret(model);
} }
private ModelView require(UUID id) { private ModelView require(UUID id) {
return jdbc.sql(MODEL_SELECT + " WHERE id = :id") QueryWrapper query = modelViewQuery()
.param("id", id) .where(ModelConfigEntity::getId).eq(id);
.query(ModelService::mapModel) ModelConfigEntity model = modelMapper.selectOneByQuery(query);
.optional() if (model == null) {
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在")); throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
return toModelView(model);
} }
private ModelSecret requireSecret(UUID id) { @SuppressWarnings("unchecked") // 机密配置查询只投影固定列LambdaGetter 可变参数不会引入运行期类型风险。
return jdbc.sql(""" /**
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text * 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key仅供模型调用链使用。
FROM app.model_config WHERE id = :id AND enabled *
""") * <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run
.param("id", id) * 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
.query((rs, rowNum) -> new ModelSecret( *
rs.getObject("id", UUID.class), * @param id Run 绑定的模型配置 ID
rs.getString("base_url"), * @return 可直接创建模型客户端的机密配置
rs.getString("model_id"), */
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")), public ModelSecret requireRuntimeModel(UUID id) {
contextWindow(parseCapabilities(rs.getString("capabilities_json"))))) QueryWrapper query = QueryWrapper.create()
.optional() .select(
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用")); ModelConfigEntity::getId,
ModelConfigEntity::getBaseUrl,
ModelConfigEntity::getModelId,
ModelConfigEntity::getApiKeyCiphertext,
ModelConfigEntity::getCapabilitiesJson)
.where(ModelConfigEntity::getId).eq(id)
.and(ModelConfigEntity::getEnabled).eq(true);
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
if (model == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用");
}
return toModelSecret(model);
} }
/** /**
@@ -339,19 +395,134 @@ public class ModelService implements ApplicationRunner {
} }
} }
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { /**
* 读取已有模型的保存密钥供“API Key 留空”的草稿测试临时使用。
*
* <p>查询只投影主键和密文字段,不要求模型处于启用状态,因为管理员需要先验证配置,
* 再决定是否重新启用。密钥只在服务端内存中短暂解密,永不写入响应或日志。</p>
*
* @param id 已保存模型 ID新增草稿没有 ID
* @param targetBaseUrl 已规范化的草稿 API 地址
* @return 已解密 API Key
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private String storedApiKey(UUID id, String targetBaseUrl) {
if (id == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "测试新模型需要 API Key");
}
QueryWrapper query = QueryWrapper.create()
.select(
ModelConfigEntity::getId,
ModelConfigEntity::getBaseUrl,
ModelConfigEntity::getApiKeyCiphertext)
.where(ModelConfigEntity::getId).eq(id);
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
if (model == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
// 隐藏密钥只能发往它原本绑定的地址,草稿换址必须由用户显式提供新密钥。
if (!normalizeBaseUrl(model.getBaseUrl()).equals(targetBaseUrl)) {
throw new ApiException(
HttpStatus.BAD_REQUEST,
"MODEL_KEY_REQUIRED_FOR_NEW_BASE_URL",
"API 地址变更后需要重新输入 API Key");
}
return keyCipher.decrypt(model.getApiKeyCiphertext());
}
/**
* 构造新增、更新共用的非敏感模型字段。
*
* @param id 模型主键
* @param input 接口输入
* @return 待持久化实体
*/
private ModelConfigEntity editableModel(UUID id, ModelInput input) {
ModelConfigEntity model = new ModelConfigEntity();
model.setId(id);
model.setName(input.name().trim());
model.setBaseUrl(normalizeBaseUrl(input.baseUrl()));
model.setModelId(input.modelId().trim());
model.setConfigJson(json(input.config()));
model.setCapabilitiesJson(json(input.capabilities()));
return model;
}
/**
* 原子插入或更新一个 Agent 角色的模型分配。
*/
private void upsertAssignment(String role, UUID modelId, UUID userId) {
ModelAssignmentEntity assignment = new ModelAssignmentEntity();
assignment.setRole(role);
assignment.setModelConfigId(modelId);
assignment.setAssignedBy(userId);
assignmentMapper.upsert(assignment);
}
/**
* 统计模型的 Run 引用;停用检查只关心正在执行的 Run删除检查覆盖全部历史记录。
*/
private long countRuns(UUID modelId, boolean runningOnly) {
QueryWrapper query = QueryWrapper.create()
.where(AgentRunEntity::getModelConfigId).eq(modelId);
if (runningOnly) {
query.and(AgentRunEntity::getStatus).eq("RUNNING");
}
return runMapper.selectCountByQuery(query);
}
/**
* 将包含密文的内部实体转换为模型调用所需的最小明文对象。
*/
private ModelSecret toModelSecret(ModelConfigEntity model) {
return new ModelSecret(
model.getId(),
model.getBaseUrl(),
model.getModelId(),
keyCipher.decrypt(model.getApiKeyCiphertext()),
contextWindow(parseCapabilities(model.getCapabilitiesJson())));
}
/**
* 将模型实体转换为永不包含密文或明文 Key 的接口视图。
*/
private static ModelView toModelView(ModelConfigEntity model) {
return new ModelView( return new ModelView(
rs.getObject("id", UUID.class), model.getId(),
rs.getString("name"), model.getName(),
rs.getString("provider"), model.getProvider(),
rs.getString("base_url"), model.getBaseUrl(),
rs.getString("model_id"), model.getModelId(),
rs.getString("api_key_hint"), model.getApiKeyHint(),
rs.getString("config_json"), model.getConfigJson(),
rs.getString("capabilities_json"), model.getCapabilitiesJson(),
rs.getBoolean("enabled"), Boolean.TRUE.equals(model.getEnabled()),
rs.getBoolean("is_default"), Boolean.TRUE.equals(model.getDefaultModel()),
rs.getObject("updated_at", OffsetDateTime.class)); model.getUpdatedAt());
}
/**
* 构造模型管理页面使用的安全字段投影。
*
* <p>普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 API Key 密文、
* 密钥版本和创建人等内部字段。只有模型连接和 Agent 创建路径可以读取密文。</p>
*
* @return 只包含模型接口展示字段的查询构造器
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private static QueryWrapper modelViewQuery() {
return QueryWrapper.create().select(
ModelConfigEntity::getId,
ModelConfigEntity::getName,
ModelConfigEntity::getProvider,
ModelConfigEntity::getBaseUrl,
ModelConfigEntity::getModelId,
ModelConfigEntity::getApiKeyHint,
ModelConfigEntity::getConfigJson,
ModelConfigEntity::getCapabilitiesJson,
ModelConfigEntity::getEnabled,
ModelConfigEntity::getDefaultModel,
ModelConfigEntity::getUpdatedAt);
} }
private String json(Object value) { private String json(Object value) {
@@ -362,24 +533,56 @@ public class ModelService implements ApplicationRunner {
} }
} }
/**
* 校验并规范化用户提供的 OpenAI 兼容服务根地址。
*
* <p>模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。
* 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。
* 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。</p>
*
* @param baseUrl 用户输入的服务根地址
* @return 去除末尾斜杠后的规范地址
* @throws ApiException 地址结构或协议不符合要求时抛出
*/
private String normalizeBaseUrl(String baseUrl) { private String normalizeBaseUrl(String baseUrl) {
String value = baseUrl.trim(); String value = baseUrl.trim();
try {
URI uri = URI.create(value);
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
boolean httpScheme = "http".equals(scheme) || "https".equals(scheme);
boolean stableRequestTarget = uri.getHost() != null
&& !uri.getHost().isBlank()
&& uri.getUserInfo() == null
&& uri.getRawQuery() == null
&& uri.getRawFragment() == null;
if (!httpScheme || uri.isOpaque() || !stableRequestTarget) {
throw invalidBaseUrl();
}
} catch (IllegalArgumentException exception) {
throw invalidBaseUrl();
}
while (value.endsWith("/")) { while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1); value = value.substring(0, value.length() - 1);
} }
return value; return value;
} }
/**
* 构造不回显原始地址的统一校验异常,避免地址中意外携带的凭据进入日志或接口响应。
*
* @return 模型地址校验异常
*/
private ApiException invalidBaseUrl() {
return new ApiException(
HttpStatus.BAD_REQUEST,
"MODEL_BASE_URL_INVALID",
"模型 API 地址必须是有效的 HTTP 或 HTTPS 地址,且不能包含用户信息、查询参数或片段");
}
private static String hint(String key) { private static String hint(String key) {
return "••••" + key.substring(Math.max(0, key.length() - 4)); return "••••" + key.substring(Math.max(0, key.length() - 4));
} }
private static final String MODEL_SELECT = """
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
capabilities_json, enabled, is_default, updated_at
FROM app.model_config
""";
/** /**
* 模型编辑输入。 * 模型编辑输入。
* *
@@ -391,14 +594,29 @@ public class ModelService implements ApplicationRunner {
* @param capabilities 能力声明 * @param capabilities 能力声明
*/ */
public record ModelInput( public record ModelInput(
@NotBlank String name, @NotBlank @Size(max = 100) String name,
@NotBlank String baseUrl, @NotBlank @Size(max = 500) String baseUrl,
@NotBlank String modelId, @NotBlank @Size(max = 255) String modelId,
String apiKey, @Size(max = 4096) String apiKey,
Map<String, Object> config, Map<String, Object> config,
Map<String, Object> capabilities) { Map<String, Object> capabilities) {
} }
/**
* 不落库的模型连接测试输入。
*
* @param id 已有模型 ID新增草稿为 {@code null}
* @param baseUrl 当前表单中的 API 地址
* @param modelId 当前表单中的模型标识
* @param apiKey 当前表单中的新密钥;已有模型且 API 地址未变化时留空可复用保存密钥
*/
public record ConnectionTestInput(
UUID id,
@NotBlank @Size(max = 500) String baseUrl,
@NotBlank @Size(max = 255) String modelId,
@Size(max = 4096) String apiKey) {
}
/** /**
* 对外模型视图。 * 对外模型视图。
* *

View File

@@ -1,8 +1,11 @@
package tech.easyflow.manuagent.project; package tech.easyflow.manuagent.project;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -24,7 +27,6 @@ import org.apache.tika.Tika;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -39,7 +41,7 @@ public class ProjectFileService {
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md", "pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
"png", "jpg", "jpeg", "webp", "vsdx", "dwg"); "png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final JdbcClient jdbc; private final ProjectFileMapper fileMapper;
private final UserService userService; private final UserService userService;
private final ProjectService projectService; private final ProjectService projectService;
private final Path dataRoot; private final Path dataRoot;
@@ -48,17 +50,17 @@ public class ProjectFileService {
/** /**
* 创建材料服务。 * 创建材料服务。
* *
* @param jdbc JDBC 客户端 * @param fileMapper 项目材料 Mapper
* @param userService 用户服务 * @param userService 用户服务
* @param projectService 项目服务 * @param projectService 项目服务
* @param properties 应用配置 * @param properties 应用配置
*/ */
public ProjectFileService( public ProjectFileService(
JdbcClient jdbc, ProjectFileMapper fileMapper,
UserService userService, UserService userService,
ProjectService projectService, ProjectService projectService,
AppProperties properties) { AppProperties properties) {
this.jdbc = jdbc; this.fileMapper = fileMapper;
this.userService = userService; this.userService = userService;
this.projectService = projectService; this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize(); this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
@@ -142,24 +144,19 @@ public class ProjectFileService {
Files.move(temporary, target); Files.move(temporary, target);
moved = true; moved = true;
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
jdbc.sql(""" // 数据库只保存受控路径和摘要selective insert 继续使用状态、时间字段的数据库默认值。
INSERT INTO app.project_file( ProjectFileEntity entity = new ProjectFileEntity();
id, project_id, original_name, stored_name, relative_path, mime_type, entity.setId(fileId);
extension, size_bytes, sha256, uploaded_by) entity.setProjectId(projectId);
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType, entity.setOriginalName(originalName);
:extension, :sizeBytes, :sha256, :userId) entity.setStoredName(target.getFileName().toString());
""") entity.setRelativePath(workspacePath);
.param("id", fileId) entity.setMimeType(mime);
.param("projectId", projectId) entity.setExtension(extension);
.param("originalName", originalName) entity.setSizeBytes(Files.size(target));
.param("storedName", target.getFileName().toString()) entity.setSha256(HexFormat.of().formatHex(digest.digest()));
.param("relativePath", workspacePath) entity.setUploadedBy(userId);
.param("mimeType", mime) fileMapper.insertSelective(entity);
.param("extension", extension)
.param("sizeBytes", Files.size(target))
.param("sha256", HexFormat.of().formatHex(digest.digest()))
.param("userId", userId)
.update();
return require(fileId); return require(fileId);
} catch (FileAlreadyExistsException exception) { } catch (FileAlreadyExistsException exception) {
cleanupFailedUpload(exception, temporary); cleanupFailedUpload(exception, temporary);
@@ -197,16 +194,13 @@ public class ProjectFileService {
*/ */
public List<FileView> list(UUID projectId) { public List<FileView> list(UUID projectId) {
projectService.require(projectId); projectService.require(projectId);
return jdbc.sql(""" QueryWrapper query = fileViewQuery()
SELECT id, project_id, original_name, relative_path, mime_type, extension, .where(ProjectFileEntity::getProjectId).eq(projectId)
size_bytes, status, created_at .and(ProjectFileEntity::getDeletedAt).isNull()
FROM app.project_file .orderBy(ProjectFileEntity::getRelativePath).asc();
WHERE project_id = :projectId AND deleted_at IS NULL return fileMapper.selectListByQuery(query).stream()
ORDER BY relative_path .map(ProjectFileService::toFileView)
""") .toList();
.param("projectId", projectId)
.query(ProjectFileService::mapFile)
.list();
} }
/** /**
@@ -216,18 +210,22 @@ public class ProjectFileService {
* @param fileId 文件 ID * @param fileId 文件 ID
* @return 文件资源 * @return 文件资源
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public Download download(UUID projectId, UUID fileId) { public Download download(UUID projectId, UUID fileId) {
StoredFile stored = jdbc.sql(""" QueryWrapper query = QueryWrapper.create()
SELECT original_name, relative_path, mime_type .select(
FROM app.project_file ProjectFileEntity::getOriginalName,
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY' ProjectFileEntity::getRelativePath,
""") ProjectFileEntity::getMimeType)
.param("fileId", fileId) .where(ProjectFileEntity::getId).eq(fileId)
.param("projectId", projectId) .and(ProjectFileEntity::getProjectId).eq(projectId)
.query((rs, rowNum) -> new StoredFile( .and(ProjectFileEntity::getDeletedAt).isNull()
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type"))) .and(ProjectFileEntity::getStatus).eq("READY");
.optional() ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在")); if (entity == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
}
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
try { try {
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri()); Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
if (!resource.exists()) { if (!resource.exists()) {
@@ -314,28 +312,54 @@ public class ProjectFileService {
} }
private FileView require(UUID fileId) { private FileView require(UUID fileId) {
return jdbc.sql(""" ProjectFileEntity entity = fileMapper.selectOneByQuery(
SELECT id, project_id, original_name, relative_path, mime_type, extension, fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
size_bytes, status, created_at if (entity == null) {
FROM app.project_file WHERE id = :id throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
""") }
.param("id", fileId) return toFileView(entity);
.query(ProjectFileService::mapFile)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
} }
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { /**
* 构造项目材料接口视图使用的最小字段投影。
*
* <p>该投影与迁移前 JDBC 列表和单条查询的显式字段保持一致,仅排除存储文件名、
* 文件摘要、上传人和更新时间等当前接口不需要的内部列。查询条件和业务判断仍由
* 调用方追加,因此本方法只承担 ORM 查询字段收敛,不改变任何业务语义。</p>
*
* @return 只包含文件接口视图字段的查询构造器
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private static QueryWrapper fileViewQuery() {
return QueryWrapper.create().select(
ProjectFileEntity::getId,
ProjectFileEntity::getProjectId,
ProjectFileEntity::getOriginalName,
ProjectFileEntity::getRelativePath,
ProjectFileEntity::getMimeType,
ProjectFileEntity::getExtension,
ProjectFileEntity::getSizeBytes,
ProjectFileEntity::getStatus,
ProjectFileEntity::getCreatedAt);
}
/**
* 将持久化实体转换为稳定的接口视图,避免把数据库字段直接暴露给控制器。
*
* @param entity 项目材料实体
* @return 文件接口视图
*/
private static FileView toFileView(ProjectFileEntity entity) {
return new FileView( return new FileView(
rs.getObject("id", UUID.class), entity.getId(),
rs.getObject("project_id", UUID.class), entity.getProjectId(),
rs.getString("original_name"), entity.getOriginalName(),
rs.getString("relative_path"), entity.getRelativePath(),
rs.getString("mime_type"), entity.getMimeType(),
rs.getString("extension"), entity.getExtension(),
rs.getLong("size_bytes"), entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
rs.getString("status"), entity.getStatus(),
rs.getObject("created_at", OffsetDateTime.class)); entity.getCreatedAt());
} }
private String safeName(String originalName) { private String safeName(String originalName) {

View File

@@ -1,7 +1,12 @@
package tech.easyflow.manuagent.project; package tech.easyflow.manuagent.project;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -10,7 +15,6 @@ import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class ProjectService { public class ProjectService {
private final JdbcClient jdbc; private final ProjectMapper projectMapper;
private final ProjectPlanMapper planMapper;
private final UserService userService; private final UserService userService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** /**
* 创建项目服务。 * 创建项目服务。
* *
* @param jdbc JDBC 客户端 * @param projectMapper 项目 Mapper
* @param planMapper 规划版本 Mapper
* @param userService 用户服务 * @param userService 用户服务
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) { public ProjectService(
this.jdbc = jdbc; ProjectMapper projectMapper,
ProjectPlanMapper planMapper,
UserService userService,
ObjectMapper objectMapper) {
this.projectMapper = projectMapper;
this.planMapper = planMapper;
this.userService = userService; this.userService = userService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -51,18 +62,14 @@ public class ProjectService {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id; String threadId = "project-" + id;
jdbc.sql(""" ProjectEntity entity = new ProjectEntity();
INSERT INTO app.project( entity.setId(id);
id, company_name, project_name, agui_thread_id, application_level, created_by) entity.setCompanyName(companyName.trim());
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId) entity.setProjectName(companyName.trim());
""") entity.setAguiThreadId(threadId);
.param("id", id) entity.setApplicationLevel(level);
.param("companyName", companyName.trim()) entity.setCreatedBy(userId);
.param("projectName", companyName.trim()) projectMapper.insertSelective(entity);
.param("threadId", threadId)
.param("level", level)
.param("userId", userId)
.update();
return require(id); return require(id);
} }
@@ -72,9 +79,10 @@ public class ProjectService {
* @return 按更新时间倒序的项目 * @return 按更新时间倒序的项目
*/ */
public List<ProjectView> list() { public List<ProjectView> list() {
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC") QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
.query(ProjectService::mapProject) return projectMapper.selectListByQuery(query).stream()
.list(); .map(ProjectService::toProjectView)
.toList();
} }
/** /**
@@ -85,11 +93,12 @@ public class ProjectService {
* @throws ApiException 项目不存在时抛出 * @throws ApiException 项目不存在时抛出
*/ */
public ProjectView require(UUID projectId) { public ProjectView require(UUID projectId) {
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id") ProjectEntity entity = projectMapper.selectOneByQuery(
.param("id", projectId) projectViewQuery().where(ProjectEntity::getId).eq(projectId));
.query(ProjectService::mapProject) if (entity == null) {
.optional() throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在")); }
return toProjectView(entity);
} }
/** /**
@@ -101,26 +110,17 @@ public class ProjectService {
@Transactional @Transactional
public void delete(UUID projectId) { public void delete(UUID projectId) {
require(projectId); require(projectId);
boolean running = jdbc.sql(""" if (projectMapper.hasRunningRun(projectId)) {
SELECT EXISTS(
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
)
""")
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (running) {
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务"); throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
} }
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) { // 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId") projectMapper.deleteEvents(projectId);
.param("projectId", projectId) projectMapper.deleteArtifacts(projectId);
.update(); projectMapper.deletePlans(projectId);
} projectMapper.deleteFiles(projectId);
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId") projectMapper.deleteRuns(projectId);
.param("projectId", projectId) int deleted = projectMapper.deleteById(projectId);
.update();
if (deleted != 1) { if (deleted != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"); throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
} }
@@ -133,14 +133,7 @@ public class ProjectService {
* @param status 新阶段 * @param status 新阶段
*/ */
public void updateStatus(UUID projectId, String status) { public void updateStatus(UUID projectId, String status) {
int updated = jdbc.sql(""" int updated = projectMapper.updateStatus(projectId, status);
UPDATE app.project
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("status", status)
.param("id", projectId)
.update();
if (updated != 1) { if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"); throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
} }
@@ -156,23 +149,14 @@ public class ProjectService {
*/ */
@Transactional @Transactional
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) { public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id") ProjectPlanEntity draft = new ProjectPlanEntity();
.param("id", projectId) draft.setId(UUID.randomUUID());
.query(Integer.class) draft.setProjectId(projectId);
.single(); draft.setPlanJson(plan.toString());
UUID planId = UUID.randomUUID(); draft.setCreatedBy(userId);
jdbc.sql(""" ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId)
""")
.param("id", planId)
.param("projectId", projectId)
.param("version", version)
.param("plan", plan.toString())
.param("userId", userId)
.update();
updateStatus(projectId, "PLANNING"); updateStatus(projectId, "PLANNING");
return requirePlan(planId); return toPlanView(stored);
} }
/** /**
@@ -182,17 +166,8 @@ public class ProjectService {
* @return 最新规划;不存在时返回空 * @return 最新规划;不存在时返回空
*/ */
public PlanView currentPlan(UUID projectId) { public PlanView currentPlan(UUID projectId) {
return jdbc.sql(""" ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at return entity == null ? null : toPlanView(entity);
FROM app.project_plan
WHERE project_id = :projectId
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
LIMIT 1
""")
.param("projectId", projectId)
.query(this::mapPlan)
.optional()
.orElse(null);
} }
/** /**
@@ -207,61 +182,65 @@ public class ProjectService {
@Transactional @Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) { public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
int updated = jdbc.sql(""" ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
UPDATE app.project_plan if (confirmed == null) {
SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId,
confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT'
""")
.param("plan", plan.toString())
.param("userId", userId)
.param("planId", planId)
.param("projectId", projectId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在"); throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
} }
updateStatus(projectId, "WRITING"); updateStatus(projectId, "WRITING");
return requirePlan(planId); return toPlanView(confirmed);
} }
private PlanView requirePlan(UUID planId) { /** 将规划实体解析成包含 JsonNode 的接口视图。 */
return jdbc.sql(""" private PlanView toPlanView(ProjectPlanEntity entity) {
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan WHERE id = :id
""")
.param("id", planId)
.query(this::mapPlan)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在"));
}
private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
try { try {
return new PlanView( return new PlanView(
rs.getObject("id", UUID.class), entity.getId(),
rs.getObject("project_id", UUID.class), entity.getProjectId(),
rs.getInt("plan_version"), entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
rs.getString("status"), entity.getStatus(),
objectMapper.readTree(rs.getString("plan_json")), objectMapper.readTree(entity.getPlanJson()),
rs.getObject("confirmed_at", OffsetDateTime.class), entity.getConfirmedAt(),
rs.getObject("created_at", OffsetDateTime.class)); entity.getCreatedAt());
} catch (JsonProcessingException exception) { } catch (JsonProcessingException exception) {
throw new java.sql.SQLException("规划 JSON 无法解析", exception); // 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。
throw new IllegalStateException("规划 JSON 无法解析", exception);
} }
} }
private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { /** 将项目实体转换为接口视图。 */
private static ProjectView toProjectView(ProjectEntity entity) {
return new ProjectView( return new ProjectView(
rs.getObject("id", UUID.class), entity.getId(),
rs.getString("company_name"), entity.getCompanyName(),
rs.getString("project_name"), entity.getProjectName(),
rs.getString("agui_thread_id"), entity.getAguiThreadId(),
rs.getString("application_level"), entity.getApplicationLevel(),
rs.getString("status"), entity.getStatus(),
rs.getLong("version"), entity.getVersion() == null ? 0L : entity.getVersion(),
rs.getObject("created_at", OffsetDateTime.class), entity.getCreatedAt(),
rs.getObject("updated_at", OffsetDateTime.class)); entity.getUpdatedAt());
}
/**
* 构造项目接口列表与详情共用的最小字段投影。
*
* <p>字段集合与迁移前 JDBC 查询保持一致,创建人只参与写入和审计,不属于当前项目接口响应,
* 因而不在普通列表和单条读取时加载。调用方继续负责追加排序或主键条件。</p>
*
* @return 只包含项目接口视图字段的查询构造器
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
private static QueryWrapper projectViewQuery() {
return QueryWrapper.create().select(
ProjectEntity::getId,
ProjectEntity::getCompanyName,
ProjectEntity::getProjectName,
ProjectEntity::getAguiThreadId,
ProjectEntity::getApplicationLevel,
ProjectEntity::getStatus,
ProjectEntity::getVersion,
ProjectEntity::getCreatedAt,
ProjectEntity::getUpdatedAt);
} }
private String normalizeLevel(String level) { private String normalizeLevel(String level) {
@@ -272,12 +251,6 @@ public class ProjectService {
return value; return value;
} }
private static final String PROJECT_SELECT = """
SELECT id, company_name, project_name, agui_thread_id, application_level, status,
version, created_at, updated_at
FROM app.project
""";
/** /**
* 项目视图。 * 项目视图。
* *

View File

@@ -1,8 +1,12 @@
package tech.easyflow.manuagent.skill; package tech.easyflow.manuagent.skill;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; 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 io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException; import java.io.IOException;
@@ -18,7 +22,6 @@ import java.util.UUID;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -32,7 +35,7 @@ public class SkillService {
private static final int MAX_ZIP_ENTRIES = 500; private static final int MAX_ZIP_ENTRIES = 500;
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024; private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
private final JdbcClient jdbc; private final SkillConfigMapper skillMapper;
private final PostgresSkillRepository repository; private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader; private final SkillPackageReader packageReader;
private final UserService userService; private final UserService userService;
@@ -41,19 +44,19 @@ public class SkillService {
/** /**
* 创建 Skill 服务。 * 创建 Skill 服务。
* *
* @param jdbc JDBC 客户端 * @param skillMapper 应用 Skill 配置 Mapper
* @param repository AgentScope PostgreSQL 仓库 * @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器 * @param packageReader Skill 包读取器
* @param userService 用户服务 * @param userService 用户服务
* @param properties 应用配置 * @param properties 应用配置
*/ */
public SkillService( public SkillService(
JdbcClient jdbc, SkillConfigMapper skillMapper,
PostgresSkillRepository repository, PostgresSkillRepository repository,
SkillPackageReader packageReader, SkillPackageReader packageReader,
UserService userService, UserService userService,
AppProperties properties) { AppProperties properties) {
this.jdbc = jdbc; this.skillMapper = skillMapper;
this.repository = repository; this.repository = repository;
this.packageReader = packageReader; this.packageReader = packageReader;
this.userService = userService; this.userService = userService;
@@ -66,9 +69,9 @@ public class SkillService {
* @return Skill 列表 * @return Skill 列表
*/ */
public List<SkillView> list() { public List<SkillView> list() {
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name") return skillMapper.selectViews().stream()
.query(SkillService::mapSkill) .map(SkillService::toSkillView)
.list(); .toList();
} }
/** /**
@@ -78,11 +81,11 @@ public class SkillService {
* @return Skill 详情 * @return Skill 详情
*/ */
public SkillDetail require(String name) { public SkillDetail require(String name) {
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name") SkillViewRow row = skillMapper.selectView(name);
.param("name", name) if (row == null) {
.query(SkillService::mapSkill) throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
.optional() }
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在")); SkillView view = toSkillView(row);
AgentSkill skill = repository.getSkill(name); AgentSkill skill = repository.getSkill(name);
if (skill == null) { if (skill == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在"); throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
@@ -116,13 +119,7 @@ public class SkillService {
* @param enabled 是否启用 * @param enabled 是否启用
*/ */
public void setEnabled(String name, boolean enabled) { public void setEnabled(String name, boolean enabled) {
int updated = jdbc.sql(""" int updated = skillMapper.updateEnabled(name, enabled);
UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
WHERE skill_name = :name AND validation_status = 'VALID'
""")
.param("enabled", enabled)
.param("name", name)
.update();
if (updated != 1) { if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过"); throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
} }
@@ -134,14 +131,15 @@ public class SkillService {
* *
* @return Skill 名称数组 * @return Skill 名称数组
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public String[] enabledNames() { public String[] enabledNames() {
return jdbc.sql(""" QueryWrapper query = QueryWrapper.create()
SELECT skill_name FROM app.skill_config .select(SkillConfigEntity::getSkillName)
WHERE enabled AND validation_status = 'VALID' .where(SkillConfigEntity::getEnabled).eq(true)
ORDER BY skill_name .and(SkillConfigEntity::getValidationStatus).eq("VALID")
""") .orderBy(SkillConfigEntity::getSkillName).asc();
.query(String.class) return skillMapper.selectListByQuery(query).stream()
.list() .map(SkillConfigEntity::getSkillName)
.toArray(String[]::new); .toArray(String[]::new);
} }
@@ -171,17 +169,17 @@ public class SkillService {
} }
repository.save(List.of(skillPackage.skill()), false); repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
jdbc.sql(""" SkillConfigEntity config = new SkillConfigEntity();
INSERT INTO app.skill_config( config.setSkillName(name);
skill_name, version, source_type, enabled, read_only, checksum, config.setVersion(skillPackage.version());
validation_status, imported_by) config.setSourceType("IMPORTED");
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId) config.setEnabled(false);
""") config.setReadOnly(true);
.param("name", name) config.setChecksum(skillPackage.checksum());
.param("version", skillPackage.version()) config.setValidationStatus("VALID");
.param("checksum", skillPackage.checksum()) config.setImportedBy(userId);
.param("userId", userId) // Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
.update(); skillMapper.insertSelectiveWithPk(config);
return require(name).view(); return require(name).view();
} finally { } finally {
deleteTree(temporary); deleteTree(temporary);
@@ -197,13 +195,16 @@ public class SkillService {
* @param name Skill 名称 * @param name Skill 名称
*/ */
@Transactional @Transactional
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public void deleteImported(String name) { public void deleteImported(String name) {
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name") QueryWrapper query = QueryWrapper.create()
.param("name", name) .select(SkillConfigEntity::getSourceType)
.query(String.class) .where(SkillConfigEntity::getSkillName).eq(name);
.optional() SkillConfigEntity config = skillMapper.selectOneByQuery(query);
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在")); if (config == null) {
if (!"IMPORTED".equals(sourceType)) { throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
}
if (!"IMPORTED".equals(config.getSourceType())) {
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除"); throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
} }
repository.delete(name); repository.delete(name);
@@ -311,26 +312,22 @@ public class SkillService {
} }
} }
private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { /**
* 将跨 schema 的只读查询行转换成对外 Skill 视图。
*/
private static SkillView toSkillView(SkillViewRow row) {
return new SkillView( return new SkillView(
rs.getString("name"), row.getName(),
rs.getString("description"), row.getDescription(),
rs.getString("version"), row.getVersion(),
rs.getString("source_type"), row.getSourceType(),
rs.getBoolean("enabled"), Boolean.TRUE.equals(row.getEnabled()),
rs.getBoolean("read_only"), Boolean.TRUE.equals(row.getReadOnly()),
rs.getString("validation_status"), row.getValidationStatus(),
rs.getString("validation_message"), row.getValidationMessage(),
rs.getObject("updated_at", OffsetDateTime.class)); row.getUpdatedAt());
} }
private static final String SKILL_SELECT = """
SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
c.validation_status, c.validation_message, c.updated_at
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
""";
/** /**
* Skill 列表视图。 * Skill 列表视图。
* *

View File

@@ -0,0 +1,56 @@
package tech.easyflow.manuagent.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
/**
* 将 JSON 文本映射到数据库的原生 JSON/JSONB 列。
*
* <p>当前 PostgreSQL 阶段使用 JDBC {@link Types#OTHER} 发送 JSON 文本,使驱动按目标列类型完成绑定,
* 避免在业务 SQL 中重复书写字符串拼接或手工创建驱动专有对象。未来适配国产数据库时,只需替换
* 该类型处理器或按数据库方言提供对应实现,实体和服务层无需感知。</p>
*/
@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.OTHER)
public class JsonbStringTypeHandler extends BaseTypeHandler<String> {
/**
* 以数据库原生扩展类型绑定非空 JSON 文本。
*
* @param statement 预编译语句
* @param index 参数位置
* @param parameter JSON 文本
* @param jdbcType MyBatis 推断的 JDBC 类型
* @throws SQLException 参数绑定失败时抛出
*/
@Override
public void setNonNullParameter(
PreparedStatement statement, int index, String parameter, JdbcType jdbcType) throws SQLException {
statement.setObject(index, parameter, Types.OTHER);
}
/** 按列名读取 JSON 文本。 */
@Override
public String getNullableResult(ResultSet resultSet, String columnName) throws SQLException {
return resultSet.getString(columnName);
}
/** 按列序号读取 JSON 文本。 */
@Override
public String getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException {
return resultSet.getString(columnIndex);
}
/** 从存储过程结果读取 JSON 文本。 */
@Override
public String getNullableResult(CallableStatement statement, int columnIndex) throws SQLException {
return statement.getString(columnIndex);
}
}

View File

@@ -0,0 +1,98 @@
package tech.easyflow.manuagent.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
/**
* 在 Java {@link UUID} 与数据库 UUID 值之间进行显式转换。
*
* <p>PostgreSQL 驱动读取 {@code uuid} 列时通常直接返回 {@link UUID},但不同驱动或查询表达式也可能
* 返回字符串。该处理器同时兼容两种结果,并通过 {@link PreparedStatement#setObject(int, Object)}
* 保留数据库驱动对原生 UUID 类型的绑定能力,避免把 UUID 降级成易产生隐式转换的 VARCHAR。</p>
*/
@MappedTypes(UUID.class)
@MappedJdbcTypes(value = JdbcType.OTHER, includeNullJdbcType = true)
public class UuidTypeHandler extends BaseTypeHandler<UUID> {
/**
* 将非空 UUID 作为驱动原生对象写入预编译语句。
*
* @param statement 预编译语句
* @param index 参数位置
* @param parameter 待写入的 UUID
* @param jdbcType MyBatis 推断的 JDBC 类型
* @throws SQLException 数据库驱动拒绝绑定参数时抛出
*/
@Override
public void setNonNullParameter(
PreparedStatement statement, int index, UUID parameter, JdbcType jdbcType) throws SQLException {
statement.setObject(index, parameter);
}
/**
* 按列名读取 UUID。
*
* @param resultSet 查询结果集
* @param columnName 列名
* @return UUID数据库值为空时返回 {@code null}
* @throws SQLException 读取或转换失败时抛出
*/
@Override
public UUID getNullableResult(ResultSet resultSet, String columnName) throws SQLException {
return toUuid(resultSet.getObject(columnName));
}
/**
* 按列序号读取 UUID。
*
* @param resultSet 查询结果集
* @param columnIndex 列序号
* @return UUID数据库值为空时返回 {@code null}
* @throws SQLException 读取或转换失败时抛出
*/
@Override
public UUID getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException {
return toUuid(resultSet.getObject(columnIndex));
}
/**
* 从存储过程结果中读取 UUID。
*
* @param statement 存储过程调用语句
* @param columnIndex 列序号
* @return UUID数据库值为空时返回 {@code null}
* @throws SQLException 读取或转换失败时抛出
*/
@Override
public UUID getNullableResult(CallableStatement statement, int columnIndex) throws SQLException {
return toUuid(statement.getObject(columnIndex));
}
/**
* 统一处理驱动返回的 UUID 对象或字符串。
*
* @param value 驱动返回值
* @return 规范化后的 UUID空值返回 {@code null}
* @throws SQLException 返回值不是合法 UUID 时抛出并保留数据库访问语义
*/
private UUID toUuid(Object value) throws SQLException {
if (value == null) {
return null;
}
if (value instanceof UUID uuid) {
return uuid;
}
try {
return UUID.fromString(value.toString());
} catch (IllegalArgumentException exception) {
throw new SQLException("数据库返回了无法转换为 UUID 的值: " + value, exception);
}
}
}

View File

@@ -21,6 +21,16 @@ spring:
jackson: jackson:
default-property-inclusion: non_null default-property-inclusion: non_null
mybatis-flex:
mapper-locations:
- classpath*:/mapper/**/*.xml
type-aliases-package: tech.easyflow.manuagent.entity
type-handlers-package: tech.easyflow.manuagent.typehandler
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
local-cache-scope: statement
server: server:
port: 8080 port: 8080
servlet: servlet:
@@ -32,14 +42,10 @@ server:
app: app:
data-root: file:../data data-root: file:../data
deepseek-key-file: ./deepseek_key.txt
dashscope-key-file: ./dashscope_key.txt dashscope-key-file: ./dashscope_key.txt
master-key: smart-factory-local-master-key master-key: smart-factory-local-master-key
admin-username: admin admin-username: admin
admin-password: admin123 admin-password: admin123
model-base-url: https://api.deepseek.com
model-id: deepseek-v4-flash
model-context-window: 131072
sandbox-image: smart-factory-agent-runtime:0.1.0 sandbox-image: smart-factory-agent-runtime:0.1.0
sandbox-network: bridge sandbox-network: bridge
run-timeout: 60m run-timeout: 60m

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.AgentEventMapper">
<!-- 显式结果映射避免 payload 列与实体 payloadJson 属性名称不同而丢失事件负载。 -->
<resultMap id="agentEventResultMap" type="tech.easyflow.manuagent.entity.AgentEventEntity">
<id property="id" column="id"/>
<result property="projectId" column="project_id"
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
<result property="runId" column="run_id"
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
<result property="eventType" column="event_type"/>
<result property="eventId" column="event_id"/>
<result property="payloadJson" column="payload"
typeHandler="tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<!--
PostgreSQL INSERT ... RETURNING 同时完成写入和序号读取,不使用“先插入、再查最大值”
这种在并发场景下会取错事件的实现。affectData 保留正确的事务与缓存语义。
-->
<select id="insertReturning" resultMap="agentEventResultMap" affectData="true" flushCache="true">
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
VALUES (
#{event.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{event.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{event.eventType},
#{event.eventId},
#{event.payloadJson, jdbcType=OTHER,
typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
<!-- 与迁移前 JDBC 返回字段一致event_id 已完成持久化,但无需再次回传给业务层。 -->
RETURNING id, project_id, run_id, event_type, payload, created_at
</select>
<!-- PostgreSQL JSONB 运算仅封装在数据库适配层,业务服务不感知方言细节。 -->
<select id="selectLatestStartedPhase" resultType="string">
SELECT payload -&gt;&gt; 'phase'
FROM app.agent_event
WHERE run_id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND event_type = 'RUN_STARTED'
ORDER BY id DESC
LIMIT 1
</select>
<select id="selectLatestMaterialResponseJson" resultType="string">
SELECT payload::text
FROM app.agent_event
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND event_type = 'ASK_RESPONDED'
AND jsonb_typeof(payload -&gt; 'decisions') = 'array'
ORDER BY id DESC
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.AgentRunMapper">
<!-- 以下更新均将“当前状态”写进 WHERE更新行数就是状态机竞争结果。 -->
<update id="completeWaiting">
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND status = 'WAITING_INPUT'
</update>
<update id="interruptRunning">
UPDATE app.agent_run
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}
AND status = 'RUNNING'
</update>
<update id="waitForInput">
UPDATE app.agent_run
SET status = 'WAITING_INPUT',
pending_interrupt = #{interruptJson, jdbcType=OTHER,
typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
</update>
<update id="completeRunning">
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}
AND status = 'RUNNING'
</update>
<update id="failRunning">
UPDATE app.agent_run
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}
AND status = 'RUNNING'
</update>
<update id="interruptRunningAfterRestart">
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'RUNNING'
</update>
</mapper>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ArtifactMapper">
<!--
PostgreSQL 的 INSERT ... RETURNING 属于会修改数据的查询语句。
affectData 与 flushCache 确保 MyBatis 按 DML 事务语义处理并清理一级缓存。
-->
<select id="upsert"
resultType="tech.easyflow.manuagent.entity.ArtifactEntity"
affectData="true"
flushCache="true">
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type,
size_bytes, sha256, metadata_json)
VALUES (
#{artifact.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.kind}, #{artifact.name}, #{artifact.relativePath}, #{artifact.mimeType},
#{artifact.sizeBytes}, #{artifact.sha256},
#{artifact.metadataJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
ON CONFLICT (project_id, relative_path) DO UPDATE SET
run_id = EXCLUDED.run_id,
kind = EXCLUDED.kind,
name = EXCLUDED.name,
mime_type = EXCLUDED.mime_type,
size_bytes = EXCLUDED.size_bytes,
sha256 = EXCLUDED.sha256,
metadata_json = EXCLUDED.metadata_json,
published_at = CURRENT_TIMESTAMP
<!-- 发布接口只需要产物视图字段,下载字段由独立下载查询按需读取。 -->
RETURNING id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ModelAssignmentMapper">
<!-- 角色为主键,单语句 upsert 避免并发设置默认模型时出现先查后写竞态。 -->
<insert id="upsert">
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
VALUES (
#{assignment.role},
#{assignment.modelConfigId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{assignment.assignedBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler})
ON CONFLICT (role) DO UPDATE SET
model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
</insert>
<delete id="deleteByModelConfigId">
DELETE FROM app.model_assignment
WHERE model_config_id = #{modelConfigId,
typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ModelConfigMapper">
<!--
JSONB 参数必须显式使用 JsonbStringTypeHandler。这样即使 Lambda Wrapper 在 Spring 初始化前
触发了 MyBatis-Flex 的全局 TableInfo 缓存,模型写入仍不会退化为 VARCHAR 参数绑定。
-->
<insert id="insertModel">
INSERT INTO app.model_config(
id, name, provider, base_url, model_id,
api_key_ciphertext, api_key_hint, key_version,
config_json, capabilities_json, enabled, is_default, created_by)
VALUES (
#{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{model.name},
#{model.provider},
#{model.baseUrl},
#{model.modelId},
#{model.apiKeyCiphertext},
#{model.apiKeyHint},
#{model.keyVersion},
#{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
#{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
COALESCE(#{model.enabled}, TRUE),
COALESCE(#{model.defaultModel}, FALSE),
#{model.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler})
</insert>
<!--
API Key 为空表示保留已有密钥;更新时间统一由数据库生成,避免应用时钟和数据库时钟混用。
-->
<update id="updateModel">
UPDATE app.model_config
SET name = #{model.name},
base_url = #{model.baseUrl},
model_id = #{model.modelId},
config_json = #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
capabilities_json = #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
<if test="model.apiKeyCiphertext != null">
api_key_ciphertext = #{model.apiKeyCiphertext},
api_key_hint = #{model.apiKeyHint},
key_version = #{model.keyVersion},
</if>
updated_at = CURRENT_TIMESTAMP
WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
<!-- 以下两条语句保持迁移前的执行顺序和条件,不额外引入模型启用状态判断。 -->
<update id="clearDefault">
UPDATE app.model_config
SET is_default = FALSE
WHERE is_default
</update>
<update id="setDefault">
UPDATE app.model_config
SET is_default = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
<update id="setEnabled">
UPDATE app.model_config
SET enabled = #{enabled},
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
<delete id="deleteModel">
DELETE FROM app.model_config
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectMapper">
<!-- 项目删除前必须先阻止仍在运行的任务。 -->
<select id="hasRunningRun" resultType="boolean">
SELECT EXISTS(
SELECT 1 FROM app.agent_run
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
)
</select>
<!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 -->
<delete id="deleteEvents">
DELETE FROM app.agent_event
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteArtifacts">
DELETE FROM app.artifact
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
<delete id="deletePlans">
DELETE FROM app.project_plan
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteFiles">
DELETE FROM app.project_file
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteRuns">
DELETE FROM app.agent_run
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
<update id="updateStatus">
UPDATE app.project
SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
</mapper>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectPlanMapper">
<!--
版本号计算与写入保持在同一条 PostgreSQL 语句内;唯一约束继续作为并发冲突的最终保护。
-->
<select id="insertNextDraft"
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
affectData="true"
flushCache="true">
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
SELECT
#{plan.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
COALESCE(MAX(plan_version), 0) + 1,
'DRAFT',
#{plan.planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
#{plan.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
FROM app.project_plan
WHERE project_id = #{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
</select>
<select id="selectCurrent" resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity">
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}
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
LIMIT 1
</select>
<!-- 条件更新和 RETURNING 在同一语句中完成,避免确认状态检查与写入之间出现竞态。 -->
<select id="confirmDraft"
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
affectData="true"
flushCache="true">
UPDATE app.project_plan
SET status = 'CONFIRMED',
plan_json = #{planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
confirmed_by = #{userId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
confirmed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = #{planId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND status = 'DRAFT'
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
</select>
</mapper>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.SkillConfigMapper">
<!-- AgentScope 表严格只读;应用只在 app.skill_config 保存启停、来源和校验状态。 -->
<sql id="skillViewColumns">
s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
c.validation_status, c.validation_message, c.updated_at
</sql>
<select id="selectViews" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
ORDER BY c.source_type, s.name
</select>
<select id="selectView" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
WHERE s.name = #{name}
</select>
<!-- 保持迁移前 JDBC SQL 的过滤条件和数据库时间戳语义。 -->
<update id="updateEnabled">
UPDATE app.skill_config
SET enabled = #{enabled},
updated_at = CURRENT_TIMESTAMP
WHERE skill_name = #{name}
AND validation_status = 'VALID'
</update>
</mapper>

View File

@@ -1,24 +1,58 @@
package tech.easyflow.manuagent; package tech.easyflow.manuagent;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import tech.easyflow.manuagent.agent.AgentEventService; 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.ArtifactService;
import tech.easyflow.manuagent.artifact.DocxValidator; import tech.easyflow.manuagent.artifact.DocxValidator;
import tech.easyflow.manuagent.auth.UserService; 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 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.ProjectService;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.MybatisFlexBootstrap;
import com.mybatisflex.core.datasource.FlexDataSource;
import com.mybatisflex.core.mybatis.FlexConfiguration;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.table.TableInfo;
import com.mybatisflex.core.table.TableInfoFactory;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.io.InputStream;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.time.Duration;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@@ -77,7 +111,7 @@ class DatabaseAndEventIntegrationTest {
* 验证事件按项目全局 ID 增量回放且不重复。 * 验证事件按项目全局 ID 增量回放且不重复。
*/ */
@Test @Test
void shouldReplayEventsAfterCursorInOrder() { void shouldReplayEventsAfterCursorInOrder() throws Exception {
JdbcClient jdbc = jdbc(); JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID(); UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID(); UUID modelId = UUID.randomUUID();
@@ -87,7 +121,7 @@ class DatabaseAndEventIntegrationTest {
.param("id", userId).param("name", "u-" + userId).update(); .param("id", userId).param("name", "u-" + userId).update();
jdbc.sql(""" jdbc.sql("""
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default) INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE) VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
""").param("id", modelId).param("name", "m-" + modelId).update(); """).param("id", modelId).param("name", "m-" + modelId).update();
jdbc.sql(""" jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by) INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
@@ -99,7 +133,7 @@ class DatabaseAndEventIntegrationTest {
""").param("id", runId).param("projectId", projectId).param("modelId", modelId) """).param("id", runId).param("projectId", projectId).param("modelId", modelId)
.param("trace", UUID.randomUUID().toString()).update(); .param("trace", UUID.randomUUID().toString()).update();
AgentEventService service = new AgentEventService(jdbc, new ObjectMapper()); AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper());
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id(); 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 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(); long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
@@ -152,7 +186,7 @@ class DatabaseAndEventIntegrationTest {
Files.writeString(document, "first"); Files.writeString(document, "first");
ProjectFileService files = mock(ProjectFileService.class); ProjectFileService files = mock(ProjectFileService.class);
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document); when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
ArtifactService artifacts = new ArtifactService(jdbc, files, new DocxValidator()); ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator());
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
ArtifactService.ArtifactView first = artifacts.publish( ArtifactService.ArtifactView first = artifacts.publish(
@@ -173,7 +207,7 @@ class DatabaseAndEventIntegrationTest {
* 验证项目真删除会清除所有关联业务记录。 * 验证项目真删除会清除所有关联业务记录。
*/ */
@Test @Test
void shouldDeleteProjectRecords() { void shouldDeleteProjectRecords() throws Exception {
JdbcClient jdbc = jdbc(); JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID(); UUID userId = UUID.randomUUID();
UUID projectId = UUID.randomUUID(); UUID projectId = UUID.randomUUID();
@@ -213,7 +247,8 @@ class DatabaseAndEventIntegrationTest {
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId) """).param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
.param("sha", "0".repeat(64)).update(); .param("sha", "0".repeat(64)).update();
ProjectService service = new ProjectService(jdbc, mock(UserService.class), new ObjectMapper()); ProjectService service = new ProjectService(
projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper());
service.delete(projectId); service.delete(projectId);
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) { for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
@@ -229,11 +264,489 @@ class DatabaseAndEventIntegrationTest {
.single()).isZero(); .single()).isZero();
} }
/**
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
*
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
*/
@Test
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID projectId = 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", "run-u-" + userId).update();
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
jdbc.sql("""
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
""").param("id", modelId).param("name", "run-m-" + modelId).update();
jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
.param("userId", userId).update();
AgentRunMapper mapper = agentRunMapper();
AgentEventMapper events = agentEventMapper();
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), new ObjectMapper());
AgentRunService.RunView initial = store.create(projectId, "INITIAL", null);
assertThat(initial.status()).isEqualTo("RUNNING");
assertThat(store.latest(projectId).id()).isEqualTo(initial.id());
store.ensureRunning(initial.id());
String interrupt = "{\"kind\":\"material_check\",\"items\":[]}";
assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1);
assertThat(new ObjectMapper().readTree(
store.requireWaiting(projectId, "material_check").pendingInterrupt()))
.isEqualTo(new ObjectMapper().readTree(interrupt));
store.completeWaiting(initial.id());
assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED");
AgentRunService.RunView resumed = store.create(projectId, "RESUME", initial.id());
AgentEventEntity started = new AgentEventEntity();
started.setProjectId(projectId);
started.setRunId(resumed.id());
started.setEventType("RUN_STARTED");
started.setEventId(UUID.randomUUID().toString());
started.setPayloadJson("{\"phase\":\"PLANNING\"}");
events.insertReturning(started);
AgentEventEntity response = new AgentEventEntity();
response.setProjectId(projectId);
response.setRunId(resumed.id());
response.setEventType("ASK_RESPONDED");
response.setEventId(UUID.randomUUID().toString());
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(mapper.interruptRunning(resumed.id())).isEqualTo(1);
assertThat(mapper.interruptRunning(resumed.id())).isZero();
assertThat(store.isInterrupted(resumed.id())).isTrue();
AgentRunService.RunView restartCandidate = store.create(projectId, "RETRY", resumed.id());
assertThat(mapper.interruptRunningAfterRestart()).isGreaterThanOrEqualTo(1);
assertThat(mapper.selectOneById(restartCandidate.id()).getErrorCode()).isEqualTo("PROCESS_RESTARTED");
}
/**
* 验证规划草稿使用递增版本写入 JSONB并且只有 DRAFT 可以原子确认。
*
* @throws Exception Mapper XML 初始化失败时抛出
*/
@Test
void shouldSaveAndConfirmProjectPlanWithMyBatisFlex() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID projectId = 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", "plan-u-" + userId).update();
jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
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();
ProjectService.PlanView draft = service.saveDraftPlan(
projectId, json.createObjectNode().put("title", "第一版"), userId);
ProjectService.PlanView confirmed = service.confirmPlan(
projectId,
draft.id(),
json.createObjectNode().put("title", "确认版"),
() -> "admin");
assertThat(draft.version()).isEqualTo(1);
assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id());
assertThat(confirmed.status()).isEqualTo("CONFIRMED");
assertThat(confirmed.plan().path("title").asText()).isEqualTo("确认版");
assertThat(service.require(projectId).status()).isEqualTo("WRITING");
assertThat(service.require(projectId).version()).isEqualTo(2L);
}
/**
* 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换。
*
* @throws Exception Mapper XML 初始化失败时抛出
*/
@Test
void shouldManageEncryptedModelConfigurationWithMyBatisFlex() throws Exception {
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(
temporaryDirectory,
temporaryDirectory.resolve("dashscope.key"),
"integration-master-key",
"admin",
"admin",
"runtime:test",
"bridge",
Duration.ofMinutes(1));
ModelService service = new ModelService(
modelConfigMapper(),
modelAssignmentMapper(),
agentRunMapper(),
users,
new KeyCipher(properties),
new ObjectMapper());
Map<String, Object> capabilities = Map.of(
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
ModelService.ModelView created = service.save(
null,
new ModelService.ModelInput(
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
Map.of("timeoutSeconds", 60), capabilities),
() -> "admin");
ModelService.ModelView updated = service.save(
created.id(),
new ModelService.ModelInput(
"测试模型更新", "https://model.example.test", "model-v2", "",
Map.of("timeoutSeconds", 120), capabilities),
() -> "admin");
ModelService.ModelView second = service.save(
null,
new ModelService.ModelInput(
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
Map.of("timeoutSeconds", 90), capabilities),
() -> "admin");
assertThat(created.defaultModel()).isTrue();
assertThat(second.defaultModel()).isFalse();
service.setDefault(second.id(), () -> "admin");
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
assertThat(updated.name()).isEqualTo("测试模型更新");
assertThat(updated.apiKeyHint()).endsWith("1234");
assertThat(firstSecret.apiKey()).isEqualTo("secret-1234");
assertThat(firstSecret.modelId()).isEqualTo("model-v2");
assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678");
assertThat(defaultSecret.modelId()).isEqualTo("model-second");
assertThat(defaultSecret.contextWindow()).isEqualTo(65_536);
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
.param("id", second.id()).query(Long.class).single()).isEqualTo(3L);
assertThatThrownBy(() -> service.setEnabled(second.id(), false))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED"));
ModelService.ModelView disabled = service.setEnabled(created.id(), false);
assertThat(disabled.enabled()).isFalse();
// 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。
UUID historyProjectId = UUID.randomUUID();
UUID historyRunId = UUID.randomUUID();
jdbc().sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId)
""").param("id", historyProjectId).param("threadId", "thread-" + historyProjectId)
.param("userId", userId).update();
jdbc().sql("""
INSERT INTO app.agent_run(
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP)
""").param("id", historyRunId).param("projectId", historyProjectId)
.param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update();
assertThatThrownBy(() -> service.delete(created.id()))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS"));
// 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。
jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update();
jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update();
service.delete(created.id());
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id")
.param("id", created.id()).query(Long.class).single()).isZero();
}
/**
* 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。
*
* @throws Exception Mapper XML 初始化失败时抛出
*/
@Test
void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception {
SkillConfigMapper mapper = skillConfigMapper();
AppProperties properties = new AppProperties(
temporaryDirectory,
temporaryDirectory.resolve("dashscope.key"),
"integration-master-key",
"admin",
"admin",
"runtime:test",
"bridge",
Duration.ofMinutes(1));
SkillService service = new SkillService(
mapper,
mock(PostgresSkillRepository.class),
mock(SkillPackageReader.class),
mock(UserService.class),
properties);
List<SkillService.SkillView> skills = service.list();
assertThat(skills).isNotEmpty();
String name = skills.getFirst().name();
service.setEnabled(name, false);
assertThat(service.enabledNames()).doesNotContain(name);
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
.param("name", name).query(Boolean.class).single()).isFalse();
}
/**
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
*/
@Test
void shouldPersistAndQueryUserWithMyBatisFlex() {
PGSimpleDataSource source = dataSource();
AppUserMapper mapper = new MybatisFlexBootstrap()
.setDataSource(source)
.addMapper(AppUserMapper.class)
.start()
.getMapper(AppUserMapper.class);
UUID userId = UUID.randomUUID();
AppUserEntity user = new AppUserEntity();
user.setId(userId);
user.setUsername("flex-" + userId);
user.setPasswordHash("encoded");
user.setDisplayName("Flex 测试用户");
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
// 主键由应用层提前生成Generator 策略必须保留已有值,其他空字段交给数据库默认值。
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
QueryWrapper query = QueryWrapper.create()
.where(AppUserEntity::getUsername).eq(user.getUsername());
AppUserEntity loaded = mapper.selectOneByQuery(query);
assertThat(loaded.getId()).isEqualTo(userId);
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
assertThat(loaded.getEnabled()).isTrue();
}
private JdbcClient jdbc() { private JdbcClient jdbc() {
return JdbcClient.create(dataSource());
}
private PGSimpleDataSource dataSource() {
PGSimpleDataSource source = new PGSimpleDataSource(); PGSimpleDataSource source = new PGSimpleDataSource();
source.setURL(POSTGRES.getJdbcUrl()); source.setURL(POSTGRES.getJdbcUrl());
source.setUser(POSTGRES.getUsername()); source.setUser(POSTGRES.getUsername());
source.setPassword(POSTGRES.getPassword()); source.setPassword(POSTGRES.getPassword());
return JdbcClient.create(source); return source;
}
/**
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
*
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations此处使用轻量 Bootstrap
* 因而需要显式复现相同配置。</p>
*
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private ArtifactMapper artifactMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
Environment environment = new Environment(
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(ArtifactMapper.class)
.start();
String resource = "mapper/ArtifactMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(ArtifactMapper.class);
}
/**
* 创建加载事件原子写入和游标回放 SQL 的 Agent 事件 Mapper。
*
* @return Agent 事件 Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private AgentEventMapper agentEventMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("agent-event-integration-test", source);
Environment environment = new Environment(
"agent-event-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(AgentEventMapper.class)
.start();
String resource = "mapper/AgentEventMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(AgentEventMapper.class);
}
/**
* 创建加载 Run 状态机 SQL 及 UUID、JSONB 类型处理器的 Agent Run Mapper。
*
* @return Agent Run Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private AgentRunMapper agentRunMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("agent-run-integration-test", source);
Environment environment = new Environment(
"agent-run-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(AgentRunMapper.class)
.start();
String resource = "mapper/AgentRunMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(AgentRunMapper.class);
}
/**
* 创建加载了项目级联删除 XML 的项目 Mapper。
*
* @return 项目 Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private ProjectMapper projectMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("project-integration-test", source);
Environment environment = new Environment(
"project-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(ProjectMapper.class)
.start();
String resource = "mapper/ProjectMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(ProjectMapper.class);
}
/**
* 创建加载了规划版本 SQL 与 JSONB 处理器的规划 Mapper。
*
* @return 规划 Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private ProjectPlanMapper projectPlanMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("plan-integration-test", source);
Environment environment = new Environment(
"plan-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(ProjectPlanMapper.class)
.start();
String resource = "mapper/ProjectPlanMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(ProjectPlanMapper.class);
}
/**
* 创建使用 MyBatis-Flex Wrapper并加载 JSONB 显式写入 SQL 的模型配置 Mapper。
*
* @return 模型配置 Mapper
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
*/
private ModelConfigMapper modelConfigMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("model-config-integration-test", source);
Environment environment = new Environment(
"model-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(ModelConfigMapper.class)
.start();
String resource = "mapper/ModelConfigMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(ModelConfigMapper.class);
}
/** 创建加载角色 upsert SQL 的模型分配 Mapper。 */
private ModelAssignmentMapper modelAssignmentMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("model-assignment-integration-test", source);
Environment environment = new Environment(
"model-assignment-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(ModelAssignmentMapper.class)
.start();
String resource = "mapper/ModelAssignmentMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(ModelAssignmentMapper.class);
}
/** 创建加载 AgentScope 只读联查 SQL 的 Skill 配置 Mapper。 */
private SkillConfigMapper skillConfigMapper() throws Exception {
PGSimpleDataSource source = dataSource();
FlexDataSource flexDataSource = new FlexDataSource("skill-config-integration-test", source);
Environment environment = new Environment(
"skill-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
.setConfiguration(configuration)
.setDataSource(flexDataSource)
.addMapper(SkillConfigMapper.class)
.start();
String resource = "mapper/SkillConfigMapper.xml";
try (InputStream input = Resources.getResourceAsStream(resource)) {
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return bootstrap.getMapper(SkillConfigMapper.class);
} }
} }

View File

@@ -28,8 +28,8 @@ class KeyCipherAndShellTest {
private AppProperties properties() { private AppProperties properties() {
return new AppProperties( return new AppProperties(
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"), Path.of("data"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123", "https://api.example.test", "model", "unit-test-master", "admin", "admin123",
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1)); "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
} }
} }

View File

@@ -0,0 +1,145 @@
package tech.easyflow.manuagent;
import static org.assertj.core.api.Assertions.assertThat;
import com.mybatisflex.spring.boot.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.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;
/**
* 验证生产环境使用的 MyBatis-Flex 自动配置、Mapper 扫描和 XML 资源能够共同启动。
*
* <p>各 PostgreSQL 集成测试使用轻量 Bootstrap 单独加载 Mapper本测试补充验证 Spring Boot
* 实际配置路径,防止 mapper-locations 拼写、Bean 扫描或 XML statement 命名错误只在部署时暴露。</p>
*/
class MyBatisFlexContextTest {
/**
* 加载最小 Spring 上下文并核对关键自定义 SQL statement。
*
* <p>测试 URL 不执行数据库连接;本用例只验证配置装配,实际 SQL 行为由 Testcontainers
* PostgreSQL 17 集成测试负责。</p>
*/
@Test
void shouldLoadMapperBeansAndXmlStatements() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
DataSourceAutoConfiguration.class,
MybatisFlexAutoConfiguration.class))
.withUserConfiguration(MyBatisFlexConfiguration.class)
.withPropertyValues(
"spring.datasource.url=jdbc:postgresql://127.0.0.1:1/config-only",
"spring.datasource.username=test",
"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.configuration.map-underscore-to-camel-case=true",
"mybatis-flex.configuration.cache-enabled=false",
"mybatis-flex.configuration.local-cache-scope=statement")
.run(context -> {
assertThat(context.getStartupFailure()).isNull();
assertThat(context.getBean(AgentEventMapper.class)).isNotNull();
assertThat(context.getBean(AgentRunMapper.class)).isNotNull();
assertThat(context.getBean(ArtifactMapper.class)).isNotNull();
assertThat(context.getBean(ModelConfigMapper.class)).isNotNull();
assertThat(context.getBean(ProjectPlanMapper.class)).isNotNull();
assertThat(context.getBean(SkillConfigMapper.class)).isNotNull();
var configuration = context.getBean(SqlSessionFactory.class).getConfiguration();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.AgentRunMapper.interruptRunningAfterRestart")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.ModelConfigMapper.insertModel")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.ModelConfigMapper.updateModel")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.ModelConfigMapper.clearDefault")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.ModelConfigMapper.setDefault")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.SkillConfigMapper.selectViews")).isTrue();
assertThat(configuration.hasStatement(
"tech.easyflow.manuagent.mapper.SkillConfigMapper.updateEnabled")).isTrue();
// 自定义 INSERT/UPDATE ... RETURNING 也应沿用迁移前视图字段,避免回传内部列。
String eventReturning = returningClause(configuration
.getMappedStatement("tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning")
.getBoundSql(Map.of("event", new AgentEventEntity()))
.getSql());
assertThat(eventReturning)
.contains("id", "project_id", "run_id", "event_type", "payload", "created_at")
.doesNotContain("event_id");
String artifactReturning = returningClause(configuration
.getMappedStatement("tech.easyflow.manuagent.mapper.ArtifactMapper.upsert")
.getBoundSql(Map.of("artifact", new ArtifactEntity()))
.getSql());
assertThat(artifactReturning)
.contains("metadata_json", "published_at", "size_bytes")
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
String draftReturning = returningClause(configuration
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.insertNextDraft")
.getBoundSql(Map.of("plan", new ProjectPlanEntity()))
.getSql());
assertPlanViewProjection(draftReturning);
String confirmReturning = returningClause(configuration
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")
.getBoundSql(Map.of())
.getSql());
assertPlanViewProjection(confirmReturning);
String currentPlanSql = configuration
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.selectCurrent")
.getBoundSql(Map.of())
.getSql()
.toLowerCase(java.util.Locale.ROOT);
assertPlanViewProjection(currentPlanSql.substring(0, currentPlanSql.indexOf("from")));
});
}
/**
* 截取自定义写语句的 RETURNING 字段部分,避免 INSERT/UPDATE 输入列干扰投影断言。
*
* @param sql 完整 Mapper SQL
* @return 规范化为小写的 RETURNING 子句
*/
private static String returningClause(String sql) {
String normalized = sql.toLowerCase(java.util.Locale.ROOT);
int returning = normalized.lastIndexOf("returning");
assertThat(returning).isGreaterThanOrEqualTo(0);
return normalized.substring(returning);
}
/**
* 断言规划查询仅返回接口视图所需字段。
*
* @param projection SELECT 或 RETURNING 字段片段
*/
private static void assertPlanViewProjection(String projection) {
assertThat(projection)
.contains("id", "project_id", "plan_version", "status", "plan_json", "confirmed_at", "created_at")
.doesNotContain("created_by", "confirmed_by", "updated_at");
}
}

View File

@@ -0,0 +1,167 @@
package tech.easyflow.manuagent;
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 com.mybatisflex.spring.boot.FlexTransactionAutoConfiguration;
import com.mybatisflex.spring.boot.MybatisFlexAutoConfiguration;
import java.util.UUID;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
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.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.aop.support.AopUtils;
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;
/**
* 使用真实 Spring 事务代理、MyBatis-Flex Mapper 和 PostgreSQL 验证跨语句回滚。
*
* <p>轻量 Mapper Bootstrap 只能证明 SQL 可执行;本测试额外证明生产配置中的 Mapper 调用
* 与 {@code @Transactional} 共享同一个数据库事务。</p>
*/
@Testcontainers
class MyBatisFlexTransactionIntegrationTest {
/** 为事务测试提供隔离的 PostgreSQL 17 数据库。 */
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
/**
* 在 Spring 上下文启动前建立与生产一致的应用表结构。
*/
@BeforeAll
static void migrate() {
Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.load()
.migrate();
}
/**
* 角色分配写入失败时,默认模型切换必须整体回滚,不能留下“没有默认模型”的中间状态。
*
* <p>目标模型刻意保持停用,用于验证 ORM 迁移没有新增原 JDBC 实现不存在的启用状态限制;
* 用户 ID 则使用数据库中不存在的值,让后续角色分配稳定触发外键错误。</p>
*/
@Test
void shouldRollbackDefaultModelSwitchWhenAssignmentFails() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
DataSourceAutoConfiguration.class,
FlexTransactionAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
TransactionAutoConfiguration.class,
MybatisFlexAutoConfiguration.class))
.withUserConfiguration(MyBatisFlexConfiguration.class, TransactionTestConfiguration.class)
.withPropertyValues(
"spring.datasource.url=" + POSTGRES.getJdbcUrl(),
"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.configuration.map-underscore-to-camel-case=true")
.run(context -> {
assertThat(context.getStartupFailure()).isNull();
JdbcClient jdbc = JdbcClient.create(context.getBean(DataSource.class));
UUID userId = UUID.randomUUID();
UUID missingUserId = UUID.randomUUID();
UUID currentDefaultId = UUID.randomUUID();
UUID enabledTargetId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, 'encoded', '事务测试用户')
""")
.param("id", userId)
.param("username", "tx-" + userId)
.update();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, enabled, is_default)
VALUES
(:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test',
'current-model', TRUE, TRUE),
(:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test',
'target-model', TRUE, FALSE)
""")
.param("currentId", currentDefaultId)
.param("currentName", "current-" + currentDefaultId)
.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"))
.hasRootCauseInstanceOf(PSQLException.class);
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
.param("id", currentDefaultId)
.query(Boolean.class)
.single()).isTrue();
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
.param("id", enabledTargetId)
.query(Boolean.class)
.single()).isFalse();
});
}
/**
* 仅装配事务测试需要的服务边界,避免启动 Agent、文件系统和外部模型连接。
*/
@Configuration(proxyBeanMethods = false)
static class TransactionTestConfiguration {
/** 提供可按测试场景设置返回值的用户服务。 */
@Bean
UserService userService() {
return mock(UserService.class);
}
/**
* 装配真实 Mapper 驱动的模型服务;未参与本场景的密钥与应用配置依赖使用边界 Mock。
*/
@Bean
ModelService modelService(
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService) {
return new ModelService(
modelMapper,
assignmentMapper,
runMapper,
userService,
mock(KeyCipher.class),
new ObjectMapper());
}
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
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.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;
/**
* 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义。
*/
class AgentEventServiceQueryTest {
/**
* 事件回放只读取响应所需字段,不加载仅用于外部追踪的事件标识。
*/
@Test
void shouldSelectOnlyEventViewColumnsWhenListingAfterCursor() {
AgentEventMapper mapper = mock(AgentEventMapper.class);
AgentEventEntity event = new AgentEventEntity();
event.setId(1L);
event.setProjectId(UUID.randomUUID());
event.setPayloadJson("{}");
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event));
AgentEventService service = new AgentEventService(mapper, new ObjectMapper());
service.listAfter(event.getProjectId(), 0L, 100);
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectListByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("project_id", "run_id", "event_type", "payload", "created_at")
.doesNotContain("event_id");
}
}

View File

@@ -1,14 +1,62 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat; 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 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 事件持久化的精简规则。 * 验证 Agent 事件持久化的精简规则。
*/ */
class AgentExecutionServiceTest { 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 正文。 * 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/ */

View File

@@ -1,20 +1,87 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat; 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.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException; import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.Test; 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 的模型重连边界。 * 验证 Agent Run 的模型重连边界。
*/ */
class AgentRunServiceTest { 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();
}
}
/** /**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。 * 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/ */

View File

@@ -0,0 +1,131 @@
package tech.easyflow.manuagent.agent;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.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.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;
/**
* 验证 Agent 执行热路径只读取判断运行状态所需的最小列。
*/
class AgentRunStoreQueryTest {
/**
* {@code ensureRunning} 可能被每个 Agent 检查点调用,因此不得加载 JSONB 和错误详情等整行数据。
*/
@Test
void shouldSelectOnlyStatusWhenCheckingRunningState() {
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunEntity running = new AgentRunEntity();
running.setStatus("RUNNING");
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(running);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
store.ensureRunning(UUID.randomUUID());
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(runMapper).selectOneByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("status")
.doesNotContain("pending_interrupt", "error_message", "trace_id");
}
/**
* 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误,
* 不能把正常的“尚未配置”状态暴露为服务端技术异常。
*/
@Test
void shouldReportMissingDefaultModelAsConfigurationConflict() {
AgentRunMapper runMapper = mock(AgentRunMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOfSatisfying(ApiException.class, exception -> {
assertThat(exception.status().value()).isEqualTo(409);
assertThat(exception.code()).isEqualTo("MODEL_NOT_CONFIGURED");
});
}
/**
* 用户恢复任务时可以显式选择替代模型;新 Run 必须保存该模型 ID
* 不能再次回退到随后可能变化的全局默认模型。
*/
@Test
void shouldCreateRunWithExplicitEnabledModel() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentRunMapper runMapper = mock(AgentRunMapper.class);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
ModelConfigEntity selectedModel = new ModelConfigEntity();
selectedModel.setId(modelId);
selectedModel.setEnabled(true);
when(modelMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(selectedModel);
AgentRunEntity storedRun = new AgentRunEntity();
storedRun.setId(UUID.randomUUID());
storedRun.setProjectId(projectId);
storedRun.setModelConfigId(modelId);
storedRun.setTriggerType("RESUME");
storedRun.setStatus("RUNNING");
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(storedRun);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
modelMapper,
new ObjectMapper());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
ArgumentCaptor<AgentRunEntity> entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class);
verify(runMapper).insertSelective(entityCaptor.capture());
assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId);
assertThat(run.modelConfigId()).isEqualTo(modelId);
}
/**
* 按 ID 强制读取或运行状态检查遇到不存在的 Run 时,不新增 404 或“已中断”业务语义。
*/
@Test
void shouldKeepMissingRequiredRunAsUnexpectedTechnicalFailure() {
AgentRunStore store = new AgentRunStore(
mock(AgentRunMapper.class),
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
UUID runId = UUID.randomUUID();
assertThatThrownBy(() -> store.require(runId))
.isInstanceOf(IllegalStateException.class)
.isNotInstanceOf(ApiException.class);
assertThatThrownBy(() -> store.ensureRunning(runId))
.isInstanceOf(IllegalStateException.class)
.isNotInstanceOf(AgentExecutionService.RunInterruptedException.class);
}
}

View File

@@ -0,0 +1,69 @@
package tech.easyflow.manuagent.artifact;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
/**
* 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围。
*/
class ArtifactServiceQueryTest {
/**
* 产物列表只应读取接口视图字段不加载下载路径、MIME、摘要和创建时间。
*/
@Test
void shouldSelectOnlyArtifactViewColumnsWhenListing() {
ArtifactMapper mapper = mock(ArtifactMapper.class);
ArtifactEntity artifact = new ArtifactEntity();
artifact.setId(UUID.randomUUID());
artifact.setProjectId(UUID.randomUUID());
artifact.setSizeBytes(1L);
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(artifact));
ArtifactService service = new ArtifactService(
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
service.list(artifact.getProjectId());
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectListByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("metadata_json", "published_at", "size_bytes")
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
}
/**
* 下载查询只应读取完整性校验和资源响应所需的六个字段。
*/
@Test
void shouldSelectOnlyStoredArtifactColumnsWhenDownloading() {
ArtifactMapper mapper = mock(ArtifactMapper.class);
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
ArtifactService service = new ArtifactService(
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
assertThatThrownBy(() -> service.download(UUID.randomUUID()))
.isInstanceOf(ApiException.class);
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectOneByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("project_id", "relative_path", "mime_type", "size_bytes", "sha256")
.doesNotContain("metadata_json", "published_at", "created_at");
}
}

View File

@@ -0,0 +1,146 @@
package tech.easyflow.manuagent.auth;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.FlexGlobalConfig;
import com.mybatisflex.core.mybatis.FlexConfiguration;
import javax.sql.DataSource;
import java.util.UUID;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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;
/**
* 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义。
*/
class UserServiceTest {
private AppUserMapper mapper;
private PasswordEncoder passwordEncoder;
private AppProperties properties;
private UserService service;
/**
* 为每个测试创建隔离的 Mapper 与安全组件替身。
*/
@BeforeEach
void setUp() {
// 生产环境由 Spring Boot 在 Mapper 使用前注册 TypeHandler纯单测需显式建立同等的元数据环境。
Environment environment = new Environment(
"user-service-unit-test", new JdbcTransactionFactory(), mock(DataSource.class));
FlexConfiguration configuration = new FlexConfiguration(environment);
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
FlexGlobalConfig globalConfig = new FlexGlobalConfig();
globalConfig.setConfiguration(configuration);
FlexGlobalConfig.setDefaultConfig(globalConfig);
mapper = mock(AppUserMapper.class);
passwordEncoder = mock(PasswordEncoder.class);
properties = mock(AppProperties.class);
service = new UserService(mapper, passwordEncoder, properties);
}
/**
* 首次启动时应创建一个启用的管理员,并保存编码后的密码。
*/
@Test
void shouldCreateInitialAdministratorWhenUserTableIsEmpty() {
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
when(properties.adminUsername()).thenReturn("admin");
when(properties.adminPassword()).thenReturn("plain-password");
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
service.run(null);
ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class);
verify(mapper).insertSelective(captor.capture());
AppUserEntity created = captor.getValue();
assertThat(created.getId()).isNotNull();
assertThat(created.getUsername()).isEqualTo("admin");
assertThat(created.getPasswordHash()).isEqualTo("encoded-password");
assertThat(created.getDisplayName()).isEqualTo("管理员");
}
/**
* 已存在用户时不得重复创建默认管理员。
*/
@Test
void shouldNotCreateAdministratorWhenAnyUserExists() {
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
service.run(null);
verify(mapper, never()).insertSelective(any(AppUserEntity.class));
}
/**
* 用户查询结果应转换为 Spring Security 用户详情,并保留禁用状态。
*/
@Test
void shouldLoadSecurityUserAndResolveUserId() {
UUID userId = UUID.randomUUID();
AppUserEntity entity = new AppUserEntity();
entity.setId(userId);
entity.setUsername("admin");
entity.setPasswordHash("encoded-password");
entity.setEnabled(false);
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
var details = service.loadUserByUsername("admin");
assertThat(details.getUsername()).isEqualTo("admin");
assertThat(details.getPassword()).isEqualTo("encoded-password");
assertThat(details.isEnabled()).isFalse();
assertThat(service.requireUserId("admin")).isEqualTo(userId);
}
/**
* 登录认证查询应保持迁移前 SQL 的三个必要字段,避免读取展示名和审计时间。
*/
@Test
void shouldSelectOnlyAuthenticationColumnsWhenLoadingUser() {
AppUserEntity entity = new AppUserEntity();
entity.setUsername("admin");
entity.setPasswordHash("encoded-password");
entity.setEnabled(true);
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
service.loadUserByUsername("admin");
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectOneByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("username", "password_hash", "enabled")
.doesNotContain("display_name", "last_login_at", "created_at", "updated_at");
}
/**
* 不存在的登录名应分别维持认证层和接口层原有的异常类型。
*/
@Test
void shouldRejectMissingUser() {
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
assertThatThrownBy(() -> service.loadUserByUsername("missing"))
.isInstanceOf(UsernameNotFoundException.class);
assertThatThrownBy(() -> service.requireUserId("missing"))
.isInstanceOf(ApiException.class);
}
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.manuagent.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.context.annotation.Configuration;
/**
* 验证应用主密钥沿用本地配置文件的读取方式。
*/
class AppPropertiesValidationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(PropertiesConfiguration.class)
.withPropertyValues(
"app.data-root=file:../data",
"app.dashscope-key-file=./dashscope_key.txt",
"app.master-key=unit-test-master-key",
"app.admin-username=admin",
"app.admin-password=admin123",
"app.sandbox-image=runtime:test",
"app.sandbox-network=bridge",
"app.run-timeout=1m");
/**
* 默认应用配置必须直接提供主密钥,使本地启动不依赖额外环境变量。
*
* @throws IOException application.yml 无法读取时抛出
*/
@Test
void shouldProvideMasterKeyInApplicationConfiguration() throws IOException {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
List<PropertySource<?>> sources = loader.load(
"application.yml",
new ClassPathResource("application.yml"));
assertThat(sources)
.extracting(source -> source.getProperty("app.master-key"))
.singleElement()
.isInstanceOf(String.class)
.asString()
.isNotBlank()
.doesNotContain("APP_MASTER_KEY");
}
/**
* 提供非空加密主密钥后,配置属性应可以正常绑定并供密钥组件使用。
*/
@Test
void shouldBindConfiguredMasterKey() {
contextRunner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(AppProperties.class).masterKey())
.isEqualTo("unit-test-master-key");
});
}
/**
* 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AppProperties.class)
static class PropertiesConfiguration {
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.manuagent.model;
import static org.mockito.ArgumentMatchers.any;
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.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
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.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 验证模型管理页面依赖的 HTTP 接口契约。
*
* <p>这些测试刻意放在控制器边界,防止前端请求方法或路径与后端映射再次发生漂移。</p>
*/
class ModelControllerTest {
/**
* 测试连接必须接收当前表单草稿,使用户无需先持久化可能无效的配置。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldTestCurrentModelDraftWithoutSavingIt() throws Exception {
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();
mvc.perform(post("/api/models/test")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "01cdd509-a79a-4503-839f-160b32d518e2",
"baseUrl": "https://draft.example.test/v1",
"modelId": "draft-model",
"apiKey": "draft-secret"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("连接正常"));
verify(service).test(new ModelService.ConnectionTestInput(
UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"),
"https://draft.example.test/v1",
"draft-model",
"draft-secret"));
}
/**
* 停用操作必须由 PATCH 状态接口处理,不能落入静态资源处理器并返回 404。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
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();
mvc.perform(patch("/api/models/{id}/enabled", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":false}"))
.andExpect(status().isOk());
verify(service).setEnabled(id, false);
}
}

View File

@@ -0,0 +1,201 @@
package tech.easyflow.manuagent.model;
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.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
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;
/**
* 验证模型草稿连接测试的密钥选择和外部请求边界。
*/
class ModelServiceConnectionTest {
/**
* Spring 容器必须能够在生产构造器和包内测试构造器之间选择生产构造器。
*
* <p>该测试防止新增辅助构造器后,应用启动阶段退化为查找不存在的无参构造器。</p>
*/
@Test
void shouldCreateModelServiceBeanWithProductionConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
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.register(ModelService.class);
context.refresh();
assertThat(context.getBean(ModelService.class)).isNotNull();
}
}
/**
* 表单提供新密钥时,应直接测试草稿地址,且不能读取或修改数据库模型配置。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldTestDraftWithSubmittedKeyWithoutReadingDatabase() throws Exception {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
UUID.randomUUID(),
"https://draft.example.test/v1/",
"draft-model",
"draft-secret"));
assertThat(result.success()).isTrue();
HttpRequest request = sentRequest(httpClient);
assertThat(request.uri()).isEqualTo(URI.create("https://draft.example.test/v1/chat/completions"));
assertThat(request.headers().firstValue("Authorization")).contains("Bearer draft-secret");
verifyNoInteractions(modelMapper);
}
/**
* 新模型没有数据库 ID 和保存密钥API Key 留空时应在发起网络请求前返回明确错误。
*/
@Test
void shouldRequireKeyWhenTestingNewModelDraft() {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
null,
"https://draft.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("测试新模型需要 API Key");
verifyNoInteractions(modelMapper, httpClient);
}
/**
* 草稿改到其他 API 主机时不能转发数据库中的隐藏密钥,必须要求用户重新输入 Key。
*/
@Test
void shouldNotForwardStoredKeyToChangedBaseUrl() {
UUID modelId = UUID.randomUUID();
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext("encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8));
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
modelId,
"https://attacker.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("API 地址变更后需要重新输入 API Key");
verifyNoInteractions(httpClient);
}
/**
* 草稿地址与保存地址一致时,允许安全复用保存密钥,避免用户为普通模型参数调整重复输入 Key。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldReuseStoredKeyWhenBaseUrlIsUnchanged() throws Exception {
UUID modelId = UUID.randomUUID();
byte[] ciphertext = "encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8);
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext(ciphertext);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
KeyCipher keyCipher = mock(KeyCipher.class);
when(keyCipher.decrypt(ciphertext)).thenReturn("stored-secret");
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, keyCipher, httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
modelId,
"https://saved.example.test/v1/",
"updated-model-id",
""));
assertThat(result.success()).isTrue();
assertThat(sentRequest(httpClient).headers().firstValue("Authorization"))
.contains("Bearer stored-secret");
}
/**
* 创建仅替换外部依赖的模型服务,生产逻辑仍由真实 {@link ModelService} 执行。
*
* @param modelMapper 模型配置 Mapper 桩
* @param keyCipher 密钥组件桩
* @param httpClient HTTP 客户端桩
* @return 待测试模型服务
*/
private ModelService service(ModelConfigMapper modelMapper, KeyCipher keyCipher, HttpClient httpClient) {
return new ModelService(
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
keyCipher,
new ObjectMapper(),
httpClient);
}
/**
* 创建固定返回 HTTP 200 的客户端,避免测试访问真实模型服务。
*
* @return HTTP 客户端桩
* @throws Exception 配置泛型 send 方法桩时抛出
*/
@SuppressWarnings("unchecked")
private HttpClient successfulHttpClient() throws Exception {
HttpClient client = mock(HttpClient.class);
HttpResponse<String> response = mock(HttpResponse.class);
when(response.statusCode()).thenReturn(200);
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response);
return client;
}
/**
* 捕获服务发送的请求,以结果状态而非内部调用顺序验证连接目标和认证头。
*
* @param client HTTP 客户端桩
* @return 捕获到的请求
* @throws Exception Mockito 验证泛型 send 方法时抛出
*/
@SuppressWarnings("unchecked")
private HttpRequest sentRequest(HttpClient client) throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
org.mockito.Mockito.verify(client).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}

View File

@@ -0,0 +1,85 @@
package tech.easyflow.manuagent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
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.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime;
import java.util.List;
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;
/**
* 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。
*/
class ModelServiceQueryTest {
/**
* 模型必须完全由管理接口和数据库维护,服务启动不得再通过 Key 文件自动灌入默认模型。
*/
@Test
void shouldNotInitializeModelConfigurationFromApplicationFiles() {
assertThat(ApplicationRunner.class.isAssignableFrom(ModelService.class)).isFalse();
}
/**
* 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。
*/
@Test
void shouldExcludeEncryptedKeyFromModelListQuery() {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(modelViewEntity()));
ModelService service = new ModelService(
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
mock(KeyCipher.class),
new ObjectMapper());
List<ModelService.ModelView> models = service.list();
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(modelMapper).selectListByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(models).hasSize(1);
assertThat(sql)
.contains("api_key_hint", "capabilities_json", "is_default")
.doesNotContain("api_key_ciphertext", "key_version");
}
/**
* 构造包含全部展示字段的实体,避免测试依赖数据库或模型密钥解密逻辑。
*
* @return 模拟数据库返回的安全投影实体
*/
private ModelConfigEntity modelViewEntity() {
ModelConfigEntity entity = new ModelConfigEntity();
entity.setId(UUID.randomUUID());
entity.setName("测试模型");
entity.setProvider("OPENAI_COMPATIBLE");
entity.setBaseUrl("https://example.test");
entity.setModelId("model");
entity.setApiKeyHint("••••1234");
entity.setConfigJson("{}");
entity.setCapabilitiesJson("{\"contextWindow\":8192}");
entity.setEnabled(true);
entity.setDefaultModel(true);
entity.setUpdatedAt(OffsetDateTime.now());
return entity;
}
}

View File

@@ -0,0 +1,93 @@
package tech.easyflow.manuagent.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 jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Map;
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;
/**
* 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确、可审计的输入边界。
*/
class ModelServiceValidationTest {
/**
* 验证名称、地址、模型标识和 API Key 的长度限制,防止超长请求占用内存或触发数据库截断异常。
*/
@Test
void shouldBoundModelInputTextFields() {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
ModelService.ModelInput input = new ModelService.ModelInput(
"".repeat(101),
"https://example.test/" + "a".repeat(500),
"m".repeat(256),
"k".repeat(4097),
Map.of(),
Map.of("contextWindow", 8_192));
// 一次构造四个越界字段,并按属性名断言,确保每个 HTTP 入参都真正受到约束。
Set<String> invalidProperties = validator.validate(input).stream()
.map(violation -> violation.getPropertyPath().toString())
.collect(Collectors.toSet());
assertThat(invalidProperties).containsExactlyInAnyOrder("name", "baseUrl", "modelId", "apiKey");
}
/**
* 验证模型地址拒绝非 HTTP(S) 协议、缺失主机以及可能改变请求语义的用户信息、查询和片段。
*/
@Test
void shouldRejectUnsafeOrAmbiguousBaseUrls() {
ModelService service = service();
for (String baseUrl : Set.of(
"file:///etc/passwd",
"https:///v1",
"https://user:password@example.test/v1",
"https://example.test/v1?tenant=other",
"https://example.test/v1#fragment")) {
ModelService.ModelInput input = new ModelService.ModelInput(
"测试模型",
baseUrl,
"model-v1",
"secret",
Map.of(),
Map.of("contextWindow", 8_192));
assertThatThrownBy(() -> service.save(null, input, () -> "admin"))
.as("地址应被拒绝:%s", baseUrl)
.isInstanceOfSatisfying(ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
}
}
/**
* 创建只执行输入校验所需路径的模型服务;无效 URL 应在任何持久化写入之前被拒绝。
*
* @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());
}
}

View File

@@ -2,18 +2,25 @@ package tech.easyflow.manuagent.project;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
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.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.springframework.jdbc.core.simple.JdbcClient; import org.mockito.ArgumentCaptor;
/** /**
* 验证项目工作区文件操作。 * 验证项目工作区文件操作。
@@ -31,11 +38,11 @@ class ProjectFileServiceTest {
@Test @Test
void shouldDeleteProjectWorkspace() throws Exception { void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties( AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model", "test-master", "admin", "admin",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); "runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService( ProjectFileService service = new ProjectFileService(
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties); mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
UUID projectId = UUID.randomUUID(); UUID projectId = UUID.randomUUID();
Path file = service.projectRoot(projectId).resolve("inputs/company.txt"); Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());
@@ -72,17 +79,50 @@ class ProjectFileServiceTest {
.isInstanceOf(ApiException.class); .isInstanceOf(ApiException.class);
} }
/**
* 文件列表应保持迁移前 SQL 的字段范围,避免加载存储文件名、摘要和上传人等内部列。
*/
@Test
void shouldSelectOnlyFileViewColumnsWhenListing() {
ProjectFileMapper mapper = mock(ProjectFileMapper.class);
ProjectService projectService = mock(ProjectService.class);
ProjectFileEntity file = new ProjectFileEntity();
file.setId(UUID.randomUUID());
file.setProjectId(UUID.randomUUID());
file.setSizeBytes(1L);
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
ProjectFileService service = new ProjectFileService(
mapper, mock(UserService.class), projectService, properties());
service.list(file.getProjectId());
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectListByQuery(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains("original_name", "relative_path", "mime_type", "size_bytes", "created_at")
.doesNotContain("stored_name", "sha256", "uploaded_by", "updated_at");
}
/** /**
* 创建使用临时数据目录的文件服务。 * 创建使用临时数据目录的文件服务。
* *
* @return 文件服务 * @return 文件服务
*/ */
private ProjectFileService service() { private ProjectFileService service() {
AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
return new ProjectFileService( return new ProjectFileService(
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties); mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties());
}
/**
* 创建测试统一使用的应用配置。
*
* @return 指向临时数据目录的配置
*/
private AppProperties properties() {
return new AppProperties(
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
} }
} }

View File

@@ -0,0 +1,127 @@
package tech.easyflow.manuagent.project;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.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.common.ApiException;
import tech.easyflow.manuagent.mapper.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
/**
* 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围。
*/
class ProjectServiceQueryTest {
/**
* 项目列表只读取接口视图字段,不加载创建人等内部字段。
*/
@Test
void shouldSelectOnlyProjectViewColumnsWhenListing() {
ProjectMapper mapper = mock(ProjectMapper.class);
ProjectEntity project = project();
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(project));
ProjectService service = service(mapper);
service.list();
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectListByQuery(queryCaptor.capture());
assertProjectViewProjection(queryCaptor.getValue());
}
/**
* 单项目读取与列表共用同一接口投影,且仍按主键精确过滤。
*/
@Test
void shouldSelectOnlyProjectViewColumnsWhenRequiringProject() {
ProjectMapper mapper = mock(ProjectMapper.class);
ProjectEntity project = project();
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(project);
ProjectService service = service(mapper);
ProjectService.ProjectView view = service.require(project.getId());
assertThat(view.id()).isEqualTo(project.getId());
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mapper).selectOneByQuery(queryCaptor.capture());
QueryWrapper query = queryCaptor.getValue();
assertProjectViewProjection(query);
assertThat(query.toSQL().toLowerCase(java.util.Locale.ROOT)).contains("where", "id");
}
/**
* 数据库存量规划 JSON 损坏仍应进入统一未预期异常路径,不新增业务错误码。
*/
@Test
void shouldKeepInvalidStoredPlanJsonAsUnexpectedTechnicalFailure() {
ProjectPlanMapper planMapper = mock(ProjectPlanMapper.class);
ProjectPlanEntity plan = new ProjectPlanEntity();
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());
assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID()))
.isInstanceOf(IllegalStateException.class)
.isNotInstanceOf(ApiException.class);
}
/**
* 创建满足项目接口映射要求的最小实体。
*
* @return 项目实体
*/
private ProjectEntity project() {
ProjectEntity project = new ProjectEntity();
project.setId(UUID.randomUUID());
project.setVersion(1L);
return project;
}
/**
* 创建仅用于查询行为验证的项目服务。
*
* @param mapper 待验证的项目 Mapper
* @return 项目服务
*/
private ProjectService service(ProjectMapper mapper) {
return new ProjectService(
mapper,
mock(ProjectPlanMapper.class),
mock(UserService.class),
new ObjectMapper());
}
/**
* 断言查询只包含迁移前项目视图 SQL 使用的字段。
*
* @param query 待检查的 MyBatis-Flex 查询
*/
private void assertProjectViewProjection(QueryWrapper query) {
String sql = query.toSQL().toLowerCase(java.util.Locale.ROOT);
assertThat(sql)
.contains(
"company_name",
"project_name",
"agui_thread_id",
"application_level",
"status",
"version",
"created_at",
"updated_at")
.doesNotContain("created_by");
}
}

7
web-ui/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
client
*.tsbuildinfo
npm-debug.log
.env
.env.*

21
web-ui/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
# 第一阶段只负责安装锁定依赖并生成 Vite 静态资源,避免把 Node.js 和源码带入运行镜像。
FROM node:22-bookworm-slim AS builder
WORKDIR /workspace
# 先复制依赖清单以复用 Docker 构建缓存;只有依赖变化时才重新执行 npm ci。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 第二阶段使用精简 Nginx 提供静态页面,并把同源 /api 请求转发给后端服务。
FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /workspace/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>智造申报 Agent</title> <title>智造申报 Agent</title>
</head> </head>
<body> <body>

33
web-ui/nginx.conf Normal file
View File

@@ -0,0 +1,33 @@
server {
listen 80;
server_name _;
# 企业材料允许上传到 160 MB与 Spring Boot 的请求上限保持一致。
client_max_body_size 160m;
root /usr/share/nginx/html;
index index.html;
# 前后端保持同源,浏览器中的 Session Cookie、CSRF Token 与流式事件接口无需额外跨域配置。
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Agent 事件采用长连接流式返回,关闭代理缓冲后事件才能及时到达前端。
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no;
}
# Vue Router 使用 history 模式,未命中的前端路由统一回退到入口页面。
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1769e8"/>
<path d="M7 9h7l2 2h9v12H7z" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

View File

@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModelsPage from './ModelsPage.vue'
const apiMock = vi.fn()
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn() },
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) }
}))
vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args)
}))
describe('ModelsPage', () => {
beforeEach(() => {
// 避免把 mockReset() 返回的 mock 函数误交给 Vitest 作为测试清理回调。
apiMock.mockReset()
})
/** 统一注册页面使用的 Element Plus 浅层桩,测试输出不应包含组件解析警告。 */
function mountModelsPage() {
return shallowMount(ModelsPage, {
global: {
renderStubDefaultSlot: true,
stubs: {
'el-button': true,
'el-input': true,
'el-input-number': true
}
}
})
}
it('数据库为空时提供新增模型入口', async () => {
apiMock.mockResolvedValueOnce([])
const wrapper = mountModelsPage()
await flushPromises()
expect(wrapper.text()).toContain('新增模型')
expect(wrapper.text()).toContain('暂无模型')
})
it('展示多个 OpenAI 兼容模型及其状态', async () => {
apiMock.mockResolvedValueOnce([
{
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: true
},
{
id: 'model-b', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://b.example.test', modelId: 'model-b', apiKeyHint: '••••5678',
configJson: '{}', capabilitiesJson: '{"contextWindow":131072}', enabled: false, defaultModel: false
}
])
const wrapper = mountModelsPage()
await flushPromises()
expect(wrapper.text()).toContain('编排模型')
expect(wrapper.text()).toContain('备用模型')
expect(wrapper.text()).toContain('OpenAI 兼容')
expect(wrapper.text()).toContain('已停用')
expect(wrapper.text()).not.toContain('DeepSeek')
})
it('测试连接时提交当前表单草稿而不是数据库旧配置', async () => {
apiMock
.mockResolvedValueOnce([{
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://old.example.test/v1', modelId: 'old-model', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
}])
.mockResolvedValueOnce({ success: true, latencyMs: 12, message: '连接正常' })
const wrapper = mountModelsPage()
await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
// 依次修改 API 地址、API Key 和模型 ID确保请求使用尚未保存的表单值。
inputs[2].vm.$emit('update:modelValue', 'https://draft.example.test/v1')
inputs[3].vm.$emit('update:modelValue', 'draft-secret')
inputs[4].vm.$emit('update:modelValue', 'draft-model')
await wrapper.vm.$nextTick()
const testButton = wrapper.findAllComponents({ name: 'ElButton' })
.find(button => button.text() === '测试连接')
await testButton!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/test', {
method: 'POST',
body: JSON.stringify({
id: 'model-a',
baseUrl: 'https://draft.example.test/v1',
modelId: 'draft-model',
apiKey: 'draft-secret'
})
})
expect(wrapper.text()).toContain('连接正常')
// 成功标记只对应发起请求时的草稿,继续编辑后必须立即失效。
inputs[4].vm.$emit('update:modelValue', 'changed-after-test')
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('连接正常')
})
it('停用模型时调用 PATCH 启用状态接口', async () => {
apiMock
.mockResolvedValueOnce([{
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
}])
.mockResolvedValueOnce({
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: false, defaultModel: false
})
.mockResolvedValueOnce([])
const wrapper = mountModelsPage()
await flushPromises()
const disableButton = wrapper.findAllComponents({ name: 'ElButton' })
.find(button => button.text() === '停用')
await disableButton!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/model-a/enabled', {
method: 'PATCH',
body: JSON.stringify({ enabled: false })
})
})
})

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleCheck } from '@element-plus/icons-vue' import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api' import { api } from '../api'
interface ModelConfig { interface ModelConfig {
@@ -19,19 +19,38 @@ interface ModelConfig {
const models = ref<ModelConfig[]>([]) const models = ref<ModelConfig[]>([])
const selectedId = ref('') const selectedId = ref('')
const creating = ref(false)
const saving = ref(false) const saving = ref(false)
const testing = ref(false) const testing = ref(false)
const tested = ref(false) const tested = ref(false)
const stateChanging = ref(false)
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 }) const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
const selected = computed(() => models.value.find(model => model.id === selectedId.value)) const selected = computed(() => models.value.find(model => model.id === selectedId.value))
const canSave = computed(() => Boolean(
form.name.trim() && form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
))
const canTest = computed(() => Boolean(
form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
))
async function load() { // “连接正常”只证明发起请求时的草稿;任一字段变化后必须重新测试。
watch(form, () => { tested.value = false })
/** 从服务端刷新模型列表,并尽量维持用户当前选中的模型。 */
async function load(preferredId?: string) {
models.value = await api<ModelConfig[]>('/api/models') models.value = await api<ModelConfig[]>('/api/models')
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '') const nextId = preferredId
|| (models.value.some(model => model.id === selectedId.value) ? selectedId.value : '')
|| models.value.find(model => model.defaultModel)?.id
|| models.value[0]?.id
|| ''
if (nextId) select(nextId)
} }
/** 将数据库模型投影到编辑表单API Key 始终保持为空,避免密钥回显。 */
function select(id: string) { function select(id: string) {
selectedId.value = id selectedId.value = id
creating.value = false
const model = models.value.find(item => item.id === id) const model = models.value.find(item => item.id === id)
if (!model) return if (!model) return
let capabilities: { contextWindow?: number } = {} let capabilities: { contextWindow?: number } = {}
@@ -46,32 +65,57 @@ function select(id: string) {
tested.value = false tested.value = false
} }
/** 进入新增模式并清空所有可能来自已有模型的可编辑字段。 */
function beginCreate() {
selectedId.value = ''
creating.value = true
tested.value = false
Object.assign(form, { name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
}
/** 创建或更新模型;新增模型的默认选择由后端事务保证。 */
async function save() { async function save() {
if (!canSave.value || saving.value) return
saving.value = true saving.value = true
try { try {
await api(`/api/models/${selectedId.value}`, { const path = creating.value ? '/api/models' : `/api/models/${selectedId.value}`
method: 'PUT', const saved = await api<ModelConfig>(path, {
method: creating.value ? 'POST' : 'PUT',
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name.trim(),
baseUrl: form.baseUrl, baseUrl: form.baseUrl.trim(),
modelId: form.modelId, modelId: form.modelId.trim(),
apiKey: form.apiKey, apiKey: form.apiKey,
config: { timeoutSeconds: 120, reasoningEffort: 'high' }, config: { timeoutSeconds: 120, reasoningEffort: 'high' },
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow } capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
}) })
}) })
ElMessage.success('已保存') ElMessage.success(creating.value ? '模型已新增' : '配置已保存')
await load() creating.value = false
await load(saved.id)
} finally { } finally {
saving.value = false saving.value = false
} }
} }
async function test() { /**
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
* 测试只验证草稿,不会隐式保存任何配置字段。
*/
async function testConnection() {
if (!canTest.value || testing.value) return
testing.value = true testing.value = true
tested.value = false tested.value = false
try { try {
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' }) await api('/api/models/test', {
method: 'POST',
body: JSON.stringify({
id: selected.value?.id || null,
baseUrl: form.baseUrl.trim(),
modelId: form.modelId.trim(),
apiKey: form.apiKey
})
})
tested.value = true tested.value = true
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '连接失败') ElMessage.error(error instanceof Error ? error.message : '连接失败')
@@ -80,43 +124,139 @@ async function test() {
} }
} }
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
async function setDefault() { async function setDefault() {
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' }) if (!selected.value || !selected.value.enabled || stateChanging.value) return
await load() stateChanging.value = true
try {
await api(`/api/models/${selected.value.id}/default`, { method: 'POST' })
await load(selected.value.id)
} finally {
stateChanging.value = false
}
} }
onMounted(load) /** 启停非默认模型;后端会阻止停用仍被运行中任务使用的模型。 */
async function toggleEnabled() {
if (!selected.value || stateChanging.value) return
const enabled = !selected.value.enabled
if (!enabled) {
try {
await ElMessageBox.confirm(`停用“${selected.value.name}”?`, '停用模型', {
confirmButtonText: '停用', cancelButtonText: '取消', type: 'warning'
})
} catch {
return
}
}
stateChanging.value = true
try {
const updated = await api<ModelConfig>(`/api/models/${selected.value.id}/enabled`, {
method: 'PATCH', body: JSON.stringify({ enabled })
})
ElMessage.success(enabled ? '模型已启用' : '模型已停用')
await load(updated.id)
} finally {
stateChanging.value = false
}
}
/** 真删除没有历史 Run 引用的非默认模型;常规模型下线优先使用停用。 */
async function removeModel() {
if (!selected.value || stateChanging.value) return
try {
await ElMessageBox.confirm(`永久删除“${selected.value.name}”?`, '删除模型', {
confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning'
})
} catch {
return
}
stateChanging.value = true
try {
await api(`/api/models/${selected.value.id}`, { method: 'DELETE' })
ElMessage.success('模型已删除')
selectedId.value = ''
await load()
if (!models.value.length) beginCreate()
} finally {
stateChanging.value = false
}
}
onMounted(async () => {
await load()
if (!models.value.length) beginCreate()
})
</script> </script>
<template> <template>
<section class="settings-page"> <section class="settings-page">
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header> <header class="settings-header">
<div><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></div>
<el-button :icon="Plus" type="primary" @click="beginCreate">新增模型</el-button>
</header>
<div class="settings-grid"> <div class="settings-grid">
<aside class="settings-list"> <aside class="settings-list">
<h2>已配置模型</h2> <h2>已配置模型 <small>{{ models.length }}</small></h2>
<div v-if="!models.length" class="model-empty">暂无模型</div>
<button <button
v-for="model in models" v-for="model in models"
:key="model.id" :key="model.id"
:class="{ selected: selectedId === model.id }" class="model-list-item"
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
@click="select(model.id)" @click="select(model.id)"
> >
<strong>{{ model.name }}</strong> <strong>{{ model.name }}</strong>
<span>DeepSeek · {{ model.modelId }}</span> <span>OpenAI 兼容 · {{ model.modelId }}</span>
<small><i></i>可用</small> <small :class="{ muted: !model.enabled }">
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
</small>
</button> </button>
</aside> </aside>
<form v-if="selected" class="settings-form" @submit.prevent="save">
<div class="form-title"><h2>{{ selected.name }}</h2><el-button v-if="!selected.defaultModel" @click="setDefault">设为默认</el-button><span v-else class="tag blue">默认</span></div> <form v-if="creating || selected" class="settings-form" @submit.prevent="save">
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label> <div class="form-title">
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label> <h2>{{ creating ? '新增模型' : selected?.name }}</h2>
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label> <div v-if="selected" class="model-title-actions">
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label> <span v-if="selected.defaultModel" class="tag blue">默认</span>
<el-button v-else :disabled="!selected.enabled" :loading="stateChanging" @click="setDefault">设为默认</el-button>
</div>
</div>
<label><span>配置名称</span><el-input v-model="form.name" maxlength="100" /></label>
<label><span>服务商</span><el-input model-value="OpenAI 兼容" disabled /></label>
<label><span>API 地址</span><el-input v-model="form.baseUrl" maxlength="500" /></label>
<label>
<span>API Key</span>
<el-input
v-model="form.apiKey"
type="password"
maxlength="4096"
show-password
:placeholder="creating ? '输入 API Key' : selected?.apiKeyHint || '留空保留现有密钥'"
/>
</label>
<label><span>模型 ID</span><el-input v-model="form.modelId" maxlength="255" /></label>
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label> <label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div> <div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
<div class="model-actions"> <div class="model-actions">
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button> <el-button native-type="submit" type="primary" :loading="saving" :disabled="!canSave">{{ creating ? '创建模型' : '保存配置' }}</el-button>
<el-button :loading="testing" @click="test">测试连接</el-button> <el-button :loading="testing" :disabled="!canTest" @click="testConnection">测试连接</el-button>
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span> <span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
<div v-if="selected" class="model-danger-actions">
<el-button :loading="stateChanging" :disabled="selected.defaultModel" @click="toggleEnabled">
{{ selected.enabled ? '停用' : '启用' }}
</el-button>
<el-button
:icon="Delete"
circle
type="danger"
plain
title="删除模型"
:loading="stateChanging"
:disabled="selected.defaultModel"
@click="removeModel"
/>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,432 @@
// @vitest-environment jsdom
import { defineComponent, h } from 'vue'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../api'
import ProjectPage from './ProjectPage.vue'
type StreamEventsCallback = (batch: AgentEvent[]) => void
const apiMock = vi.fn()
let outputResizeCallback: ResizeObserverCallback | null = null
const observeOutputMock = vi.fn()
const unobserveOutputMock = vi.fn()
const disconnectOutputObserverMock = vi.fn()
const mountedWrappers: Array<{ unmount: () => void }> = []
/**
* JSDOM 不提供 ResizeObserver这里保留组件注册的回调模拟流式 Markdown
* 在事件已经写入后仍逐帧增高的真实浏览器行为。
*/
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
outputResizeCallback = callback
}
observe = observeOutputMock
unobserve = unobserveOutputMock
disconnect = disconnectOutputObserverMock
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
const streamEventsMock = vi.fn((
_projectId: string,
_after: number,
_onEvents: StreamEventsCallback,
_onError: (error: Error) => void
) => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { id: 'project-1' } }),
useRouter: () => ({ replace: vi.fn() })
}))
vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args),
streamEvents: (
projectId: string,
after: number,
onEvents: StreamEventsCallback,
onError: (error: Error) => void
) => streamEventsMock(projectId, after, onEvents, onError)
}))
vi.mock('../eventCache', () => ({
cacheEvents: vi.fn(async () => undefined),
deleteCachedEvents: vi.fn(async () => undefined),
readCachedEvents: vi.fn(async () => [])
}))
vi.mock('element-plus', () => ({
ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() }
}))
// 测试只关心项目页发送的模型选择请求,因此用原生控件模拟 Element Plus 的 v-model 契约。
const ElButtonStub = defineComponent({
inheritAttrs: false,
setup(_, { attrs, emit, slots }) {
return () => h('button', { ...attrs, onClick: () => emit('click') }, slots.default?.())
}
})
const ElDialogStub = defineComponent({
props: { modelValue: Boolean },
setup(props, { slots }) {
return () => props.modelValue
? h('div', { role: 'dialog' }, [slots.default?.(), h('footer', slots.footer?.())])
: null
}
})
const ElSelectStub = defineComponent({
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value)
}, slots.default?.())
}
})
const ElOptionStub = defineComponent({
props: { label: String, value: String },
setup(props) {
return () => h('option', { value: props.value }, props.label)
}
})
const enabledModels = [
{ id: 'model-a', name: '当前模型', modelId: 'model-a', enabled: true, defaultModel: false },
{ id: 'model-b', name: '默认模型', modelId: 'model-b', enabled: true, defaultModel: true },
{ id: 'model-c', name: '停用模型', modelId: 'model-c', enabled: false, defaultModel: false }
]
interface ProjectFixtureOptions {
projectStatus?: 'MATERIAL_CHECK' | 'PLANNING' | 'WRITING' | 'DELIVERED' | 'FAILED' | 'ARCHIVED'
files?: Array<Record<string, unknown>>
artifacts?: Array<Record<string, unknown>>
latestRun?: { status: string; modelConfigId?: string } | null
startedRun?: { status: string; modelConfigId?: string }
}
/**
* 构造项目页的最小服务端数据集,允许每个测试只覆盖自己关心的业务状态。
* 默认仍保持原有“申报书编写中”场景,避免模型切换测试因视觉改造改变测试语义。
*/
function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixtureOptions = {}) {
apiMock.mockImplementation(async (url: string, requestOptions?: RequestInit) => {
if (url === '/api/projects/project-1') {
return {
id: 'project-1', companyName: '测试企业', projectName: '申报项目', threadId: 'thread-1',
applicationLevel: 'ADVANCED', status: options.projectStatus || 'WRITING',
createdAt: '2026-08-31T09:00:00Z', updatedAt: '2026-08-31T10:00:00Z'
}
}
if (url === '/api/projects/project-1/files') return options.files || []
if (url === '/api/projects/project-1/artifacts') return options.artifacts || []
if (url.startsWith('/api/projects/project-1/events')) return []
if (url === '/api/projects/project-1/plan') return null
if (url === '/api/projects/project-1/runs/latest') {
return options.latestRun === undefined
? { status: runStatus, modelConfigId: 'model-a' }
: options.latestRun
}
if (url === '/api/models') return enabledModels
if (url === '/api/projects/project-1/runs/material-check' && requestOptions?.method === 'POST') {
return options.startedRun || { status: 'RUNNING', modelConfigId: 'model-a' }
}
if (requestOptions?.method === 'POST') return { status: 'RUNNING' }
throw new Error(`未处理的测试请求:${url}`)
})
const wrapper = shallowMount(ProjectPage, {
global: {
stubs: {
'el-button': ElButtonStub,
'el-dialog': ElDialogStub,
'el-select': ElSelectStub,
'el-option': ElOptionStub,
'el-icon': defineComponent({ setup: (_, { slots }) => () => h('span', slots.default?.()) }),
'el-upload': defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
}
}
})
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
})
describe('ProjectPage 模型切换', () => {
beforeEach(() => {
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
apiMock.mockReset()
})
it('中断任务继续时允许选择启用模型并发送模型 ID', async () => {
const wrapper = mountProject('INTERRUPTED')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '继续')!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('停用模型')
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '继续运行')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/resume', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
it('运行中的任务可以选择替代模型并调用受控切换接口', async () => {
const wrapper = mountProject('RUNNING')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '切换模型')!.trigger('click')
await flushPromises()
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '确认切换')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/switch-model', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
})
describe('ProjectPage 项目工作区', () => {
beforeEach(() => {
apiMock.mockReset()
streamEventsMock.mockReset()
streamEventsMock.mockReturnValue(vi.fn())
outputResizeCallback = null
observeOutputMock.mockReset()
unobserveOutputMock.mockReset()
disconnectOutputObserverMock.mockReset()
})
it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => {
const wrapper = mountProject('RUNNING', {
projectStatus: 'WRITING',
files: [
{ id: 'file-1', name: '企业营业执照.pdf', relativePath: '企业营业执照.pdf', extension: 'pdf', sizeBytes: 1024, status: 'READY', createdAt: '' },
{ id: 'file-2', name: '财务报表.xlsx', relativePath: '财务报表.xlsx', extension: 'xlsx', sizeBytes: 2048, status: 'READY', createdAt: '' }
],
artifacts: [
{ id: 'artifact-1', name: '先进级申报书.docx', kind: 'DOCX', sizeBytes: 4096, metadataJson: '{}', publishedAt: '' }
]
})
await flushPromises()
const stages = wrapper.findAll('.project-stage')
expect(stages).toHaveLength(4)
expect(stages.slice(0, 2).every(stage => stage.classes().includes('is-complete'))).toBe(true)
expect(stages[2]!.text()).toContain('申报书编写')
expect(stages[2]!.attributes('aria-current')).toBe('step')
const context = wrapper.get('.project-context-panel')
expect(context.text()).toContain('当前模型')
expect(context.text()).toContain('当前模型 · model-a')
expect(context.text()).toContain('2 个文件')
expect(context.text()).toContain('企业营业执照.pdf')
expect(context.text()).toContain('1 个文件')
expect(context.text()).toContain('先进级申报书.docx')
expect(context.get('a').attributes('href')).toBe('/api/artifacts/artifact-1/download')
})
it('首次启动材料检查后立即显示本次 Run 实际绑定的模型', async () => {
const wrapper = mountProject('RUNNING', {
projectStatus: 'MATERIAL_CHECK',
latestRun: null,
startedRun: { status: 'RUNNING', modelConfigId: 'model-a' }
})
await flushPromises()
expect(wrapper.get('.current-model-name').text()).toBe('尚未选择')
await wrapper.findAll('button').find(button => button.text() === '开始材料检验')!.trigger('click')
await flushPromises()
expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a')
})
it('用户向上滚动后暂停跟随新输出,并可通过箭头回到最新位置', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1800 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1200 })
const wrapper = mountProject('RUNNING')
await flushPromises()
scrollTo.mockClear()
// 即使只向上移动 20px也已代表用户主动回看历史下一批输出不能把页面拉回底部。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
const latestButton = wrapper.get('.back-to-bottom')
expect(latestButton.attributes('aria-label')).toBe('转到最新输出')
const streamCallback = streamEventsMock.mock.calls[0]?.[2]
expect(streamCallback).toBeTypeOf('function')
streamCallback!([{
id: 1,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '新输出' },
createdAt: '2026-09-03T10:00:00Z'
}])
await flushPromises()
expect(scrollTo).not.toHaveBeenCalled()
await latestButton.trigger('click')
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标,
// 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 })
outputResizeCallback!([], {} as ResizeObserver)
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值,
// 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 })
outputResizeCallback!([], {} as ResizeObserver)
await wrapper.vm.$nextTick()
expect(scrollTo).not.toHaveBeenCalled()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 }))
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 在输入控件内使用方向键不应改变页面的自动跟随状态。
const modelInput = document.createElement('input')
document.body.appendChild(modelInput)
modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
modelInput.remove()
// 控件内部的图标/子节点事件也不应触发页面滚动状态切换。
const control = document.createElement('button')
const icon = document.createElement('span')
control.appendChild(icon)
document.body.appendChild(control)
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
control.remove()
// 空格键同样会向下翻页,回到底部后应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 })
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 向下拖动滚动条并释放到底部时才恢复跟随。
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。
window.dispatchEvent(new TouchEvent('touchmove'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
await wrapper.get('.back-to-bottom').trigger('click')
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
expect(outputResizeCallback).not.toBeNull()
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
// 即使新内容只让底部前移 20px也必须记录程序目标防止中间 scroll 事件误停跟随。
scrollTo.mockClear()
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
scrollTo.mockClear()
streamCallback!([{
id: 2,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '后续输出' },
createdAt: '2026-09-03T10:00:01Z'
}])
await flushPromises()
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
})
})

View File

@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Document, UploadFilled } from '@element-plus/icons-vue' import { Document, UploadFilled } from '@element-plus/icons-vue'
import { ArrowDown } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AgentTimeline from '../components/AgentTimeline.vue' import AgentTimeline from '../components/AgentTimeline.vue'
import MaterialAskCard from '../components/MaterialAskCard.vue' import MaterialAskCard from '../components/MaterialAskCard.vue'
@@ -10,6 +11,26 @@ import { api, streamEvents, type AgentEvent, type Artifact, type PlanView, type
import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache' import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache'
import { appendUniqueEvents } from '../eventUtils' import { appendUniqueEvents } from '../eventUtils'
interface ModelOption {
id: string
name: string
modelId: string
enabled: boolean
defaultModel: boolean
}
interface RunSummary {
status: string
modelConfigId?: string
}
const projectStageDefinitions = [
{ key: 'materials', label: '材料检查', description: '核验企业材料' },
{ key: 'planning', label: '规划确认', description: '确认建设规划' },
{ key: 'writing', label: '申报书编写', description: '生成并校验内容' },
{ key: 'delivery', label: '交付', description: '下载最终文件' }
] as const
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ 'projects-changed': [] }>() const emit = defineEmits<{ 'projects-changed': [] }>()
@@ -26,36 +47,118 @@ const controlLoading = ref(false)
const deleting = ref(false) const deleting = ref(false)
const historyLoading = ref(true) const historyLoading = ref(true)
const streamError = ref('') const streamError = ref('')
const showBackToBottom = ref(false) const followsLatestOutput = ref(true)
const currentModelConfigId = ref('')
const modelPickerVisible = ref(false)
const modelPickerLoading = ref(false)
const modelPickerMode = ref<'resume' | 'switch'>('resume')
const selectedModelConfigId = ref('')
const modelCatalog = ref<ModelOption[]>([])
const selectableModels = ref<ModelOption[]>([])
const outputContainer = ref<HTMLElement | null>(null)
let stopStream: (() => void) | null = null let stopStream: (() => void) | null = null
let outputResizeObserver: ResizeObserver | null = null
let loadVersion = 0 let loadVersion = 0
let previousScrollY = 0
let hasObservedScrollPosition = false
let pendingFollowScrollTarget: number | null = null
type UserScrollIntent = 'none' | 'up' | 'down'
let userScrollIntent: UserScrollIntent = 'none'
let touchStartY: number | null = null
let scrollbarDragLastY: number | null = null
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
let scrollbarDragActive = false
const folderInput = ref<HTMLInputElement | null>(null) const folderInput = ref<HTMLInputElement | null>(null)
const LATEST_OUTPUT_THRESHOLD = 48
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
const projectId = computed(() => String(route.params.id || '')) const projectId = computed(() => String(route.params.id || ''))
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT') const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check') const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
const running = computed(() => runStatus.value === 'RUNNING') const running = computed(() => runStatus.value === 'RUNNING')
const interrupted = computed(() => runStatus.value === 'INTERRUPTED') const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
const modelPickerTitle = computed(() => modelPickerMode.value === 'switch' ? '切换运行模型' : '选择继续运行的模型')
const modelPickerConfirmText = computed(() => modelPickerMode.value === 'switch' ? '确认切换' : '继续运行')
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级') const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({ const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档' MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
}[project.value?.status || 'MATERIAL_CHECK'])) }[project.value?.status || 'MATERIAL_CHECK']))
/**
* 将后端业务状态投影为稳定的四阶段索引。失败状态通常发生在内容生成阶段,
* 因而仍停留在“申报书编写”,方便用户理解失败位置并继续使用原有重试入口。
*/
const currentStageIndex = computed(() => ({
MATERIAL_CHECK: 0,
PLANNING: 1,
WRITING: 2,
FAILED: 2,
DELIVERED: 3,
ARCHIVED: 3
}[project.value?.status || 'MATERIAL_CHECK']))
/** 为阶段条补充完成、当前和待开始状态;这里只改变展示,不参与后端流程判断。 */
const projectStages = computed(() => projectStageDefinitions.map((stage, index) => ({
...stage,
state: project.value?.status === 'DELIVERED' || project.value?.status === 'ARCHIVED'
? 'complete'
: index < currentStageIndex.value
? 'complete'
: index === currentStageIndex.value ? 'current' : 'upcoming'
})))
const currentStageLabel = computed(() => projectStageDefinitions[currentStageIndex.value]?.label || '材料检查')
const currentModelLabel = computed(() => {
const model = modelCatalog.value.find(item => item.id === currentModelConfigId.value)
if (model) return `${model.name} · ${model.modelId}`
return currentModelConfigId.value ? '已绑定运行模型' : '尚未选择'
})
const updatedAtLabel = computed(() => {
const updatedAt = project.value?.updatedAt
if (!updatedAt) return '--'
const value = new Date(updatedAt)
return Number.isNaN(value.getTime()) ? '--' : value.toLocaleString('zh-CN', { hour12: false })
})
/**
* 把创建、恢复或切换 Run 的响应同步到页面状态。
* 后端返回的 modelConfigId 代表本次 Run 真正绑定的模型,不能继续沿用上一个 Run
* 或页面初次加载时的空值;仅在兼容旧响应时才使用调用方提供的兜底模型 ID。
*/
function syncRunState(run: RunSummary, fallbackModelConfigId = '') {
runStatus.value = run.status
const modelConfigId = run.modelConfigId || fallbackModelConfigId
if (modelConfigId) currentModelConfigId.value = modelConfigId
}
async function load(id: string) { async function load(id: string) {
const version = ++loadVersion const version = ++loadVersion
historyLoading.value = true historyLoading.value = true
followsLatestOutput.value = true
previousScrollY = window.scrollY
hasObservedScrollPosition = false
pendingFollowScrollTarget = null
userScrollIntent = 'none'
touchStartY = null
scrollbarDragLastY = null
scrollbarDragDirection = 'none'
scrollbarDragActive = false
stopStream?.() stopStream?.()
stopStream = null stopStream = null
project.value = null project.value = null
events.value = [] events.value = []
try { try {
const cached = await readCachedEvents(id).catch(() => []) const cached = await readCachedEvents(id).catch(() => [])
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest] = await Promise.all([ const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest, loadedModels] = await Promise.all([
api<Project>(`/api/projects/${id}`), api<Project>(`/api/projects/${id}`),
api<ProjectFile[]>(`/api/projects/${id}/files`), api<ProjectFile[]>(`/api/projects/${id}/files`),
api<Artifact[]>(`/api/projects/${id}/artifacts`), api<Artifact[]>(`/api/projects/${id}/artifacts`),
api<PlanView | null>(`/api/projects/${id}/plan`), api<PlanView | null>(`/api/projects/${id}/plan`),
api<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`) api<(RunSummary & { pendingInterrupt?: string }) | null>(`/api/projects/${id}/runs/latest`),
// 模型摘要只服务于右侧信息栏;加载失败不能阻断项目主体和历史事件恢复。
api<ModelOption[]>('/api/models').catch(() => [])
]) ])
const loadedEvents = await fetchMissingEvents(id, cached) const loadedEvents = await fetchMissingEvents(id, cached)
if (version !== loadVersion || id !== projectId.value) return if (version !== loadVersion || id !== projectId.value) return
@@ -65,6 +168,8 @@ async function load(id: string) {
plan.value = loadedPlan plan.value = loadedPlan
events.value = loadedEvents events.value = loadedEvents
runStatus.value = latest?.status || '' runStatus.value = latest?.status || ''
currentModelConfigId.value = latest?.modelConfigId || ''
modelCatalog.value = loadedModels
pendingAsk.value = parseAsk(latest?.pendingInterrupt) pendingAsk.value = parseAsk(latest?.pendingInterrupt)
startStream(id, version) startStream(id, version)
} finally { } finally {
@@ -196,8 +301,8 @@ async function startCheck() {
if (loading.value || folderUploading.value) return if (loading.value || folderUploading.value) return
loading.value = true loading.value = true
try { try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' }) const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
runStatus.value = run.status syncRunState(run)
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '启动失败') ElMessage.error(error instanceof Error ? error.message : '启动失败')
} finally { } finally {
@@ -209,8 +314,8 @@ async function retry() {
if (plan.value?.status === 'CONFIRMED') { if (plan.value?.status === 'CONFIRMED') {
loading.value = true loading.value = true
try { try {
await api(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' }) const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
runStatus.value = 'RUNNING' syncRunState(run)
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重试失败') ElMessage.error(error instanceof Error ? error.message : '重试失败')
} finally { } finally {
@@ -225,8 +330,8 @@ async function stopRun() {
if (!running.value || controlLoading.value) return if (!running.value || controlLoading.value) return
controlLoading.value = true controlLoading.value = true
try { try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' }) const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
runStatus.value = run.status syncRunState(run)
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败') ElMessage.error(error instanceof Error ? error.message : '停止失败')
} finally { } finally {
@@ -234,14 +339,51 @@ async function stopRun() {
} }
} }
async function resumeRun() { /**
if (!interrupted.value || controlLoading.value) return * 打开恢复或切换模型对话框,并从数据库重新读取当前启用的模型。
* 优先保留 Run 已绑定的模型;如果该模型已停用,则退回当前默认模型或首个可用模型。
*/
async function openModelPicker(mode: 'resume' | 'switch') {
if (controlLoading.value || modelPickerLoading.value) return
modelPickerMode.value = mode
modelPickerLoading.value = true
try {
const models = await api<ModelOption[]>('/api/models')
modelCatalog.value = models
selectableModels.value = models.filter(model => model.enabled)
if (!selectableModels.value.length) {
ElMessage.error('没有可用模型,请先启用模型配置')
return
}
selectedModelConfigId.value = selectableModels.value.find(model => model.id === currentModelConfigId.value)?.id
|| selectableModels.value.find(model => model.defaultModel)?.id
|| selectableModels.value[0]!.id
modelPickerVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模型列表加载失败')
} finally {
modelPickerLoading.value = false
}
}
/**
* 使用用户明确选择的模型恢复任务,或中断当前 Run 后创建新的 RESUME Run。
* 请求成功后立即更新页面中的 Run 状态和绑定模型,事件流随后会补齐完整审计事件。
*/
async function confirmModelSelection() {
if (!selectedModelConfigId.value || controlLoading.value) return
controlLoading.value = true controlLoading.value = true
try { try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' }) const endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
runStatus.value = run.status const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/${endpoint}`, {
method: 'POST',
body: JSON.stringify({ modelConfigId: selectedModelConfigId.value })
})
syncRunState(run, selectedModelConfigId.value)
modelPickerVisible.value = false
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '继续失败') const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
ElMessage.error(error instanceof Error ? error.message : fallback)
} finally { } finally {
controlLoading.value = false controlLoading.value = false
} }
@@ -289,14 +431,14 @@ async function confirmPlan(value: Record<string, unknown>) {
if (!plan.value || loading.value) return if (!plan.value || loading.value) return
loading.value = true loading.value = true
try { try {
const result = await api<{ plan: PlanView; run: { status: string } }>(`/api/projects/${projectId.value}/plan/confirm`, { const result = await api<{ plan: PlanView; run: RunSummary }>(`/api/projects/${projectId.value}/plan/confirm`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ planId: plan.value.id, plan: value }) body: JSON.stringify({ planId: plan.value.id, plan: value })
}) })
localStorage.removeItem(`plan-draft:${plan.value.id}`) localStorage.removeItem(`plan-draft:${plan.value.id}`)
plan.value = result.plan plan.value = result.plan
pendingAsk.value = null pendingAsk.value = null
runStatus.value = result.run.status syncRunState(result.run)
await refreshProject() await refreshProject()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '规划确认失败') ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
@@ -309,12 +451,12 @@ async function confirmMaterials(value: Record<string, unknown>) {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/material/confirm`, { const run = await api<RunSummary>(`/api/projects/${projectId.value}/material/confirm`, {
method: 'POST', body: JSON.stringify(value) method: 'POST', body: JSON.stringify(value)
}) })
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`) localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
pendingAsk.value = null pendingAsk.value = null
runStatus.value = run.status syncRunState(run)
await refreshProject() await refreshProject()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '材料确认失败') ElMessage.error(error instanceof Error ? error.message : '材料确认失败')
@@ -341,25 +483,239 @@ async function refreshArtifacts(id = projectId.value, version = loadVersion) {
if (version === loadVersion && id === projectId.value) artifacts.value = value if (version === loadVersion && id === projectId.value) artifacts.value = value
} }
/**
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
*/
function updateScrollState() { function updateScrollState() {
showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240 const currentScrollY = window.scrollY
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
const scrollingUp = hasObservedScrollPosition && currentScrollY < previousScrollY
const completingFollowScroll = pendingFollowScrollTarget !== null
if (completingFollowScroll) {
// 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
// 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
if (reachedRequestedTarget) {
pendingFollowScrollTarget = null
followsLatestOutput.value = true
}
} else if (scrollingUp && userScrollIntent !== 'down') {
followsLatestOutput.value = false
userScrollIntent = 'up'
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
followsLatestOutput.value = true
userScrollIntent = 'none'
}
previousScrollY = currentScrollY
hasObservedScrollPosition = true
} }
/**
* 将窗口移动到当前文档底部,并记录本次程序滚动的目标位置。
*
* <p>浏览器派发 scroll 事件晚于 scrollTo 调用,大文档还可能先派发一个或多个
* 尚未到达底部的中间事件。目标位置必须单独保存,不能写入 previousScrollY
* 否则中间坐标会因为小于目标值而被误判为用户向上滚动。</p>
*/
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
const scrollHeight = document.documentElement.scrollHeight
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
// 所有程序滚动箭头点击、事件更新、ResizeObserver统一只在真正抵达目标时
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
? null
: targetScrollY
hasObservedScrollPosition = true
const options: ScrollToOptions = { top: scrollHeight }
if (behavior) options.behavior = behavior
window.scrollTo(options)
}
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
function scrollToBottom() { function scrollToBottom() {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }) followsLatestOutput.value = true
userScrollIntent = 'none'
touchStartY = null
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
moveViewportToLatestOutput('auto')
}
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null
return Boolean(element && (
(element instanceof HTMLElement && element.isContentEditable)
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
))
}
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
function restoreFollowIfAtLatest() {
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
followsLatestOutput.value = true
userScrollIntent = 'none'
return true
}
return false
}
/**
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
*
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
*/
function handleUserWheel(event: WheelEvent) {
if (isInteractiveTarget(event.target)) return
if (event.deltaY !== 0) {
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
// 事件可能在用户上滚后再次把页面状态恢复到底部。
pendingFollowScrollTarget = null
}
if (event.deltaY < 0) {
followsLatestOutput.value = false
userScrollIntent = 'up'
} else if (event.deltaY > 0) {
userScrollIntent = 'down'
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
restoreFollowIfAtLatest()
}
}
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
function handleUserScrollbarDrag(event: MouseEvent) {
const scrollbarStart = document.documentElement.clientWidth
if (event.clientX >= scrollbarStart) {
followsLatestOutput.value = false
pendingFollowScrollTarget = null
scrollbarDragLastY = event.clientY
scrollbarDragDirection = 'none'
scrollbarDragActive = true
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
userScrollIntent = 'up'
}
}
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
function handleUserScrollbarMove(event: MouseEvent) {
if (!scrollbarDragActive || scrollbarDragLastY === null) return
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
scrollbarDragLastY = event.clientY
}
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
function handleUserMouseUp() {
if (!scrollbarDragActive) return
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
scrollbarDragActive = false
scrollbarDragLastY = null
const draggedUp = scrollbarDragDirection === 'up'
scrollbarDragDirection = 'none'
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
followsLatestOutput.value = true
userScrollIntent = 'none'
}
}
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
function handleUserTouchStart(event: TouchEvent) {
touchStartY = event.touches[0]?.clientY ?? null
}
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
function handleUserTouchMove(event: TouchEvent) {
if (isInteractiveTarget(event.target)) return
pendingFollowScrollTarget = null
const currentY = event.touches[0]?.clientY
if (touchStartY === null || currentY === undefined) {
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
followsLatestOutput.value = false
userScrollIntent = 'up'
return
}
const pageScrollDelta = touchStartY - currentY
if (pageScrollDelta < 0) {
followsLatestOutput.value = false
userScrollIntent = 'up'
} else if (pageScrollDelta > 0) {
userScrollIntent = 'down'
restoreFollowIfAtLatest()
}
}
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
function handleUserTouchEnd() {
touchStartY = null
}
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
function handleUserKeydown(event: KeyboardEvent) {
if (isInteractiveTarget(event.target)) return
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
followsLatestOutput.value = false
pendingFollowScrollTarget = null
userScrollIntent = 'up'
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
pendingFollowScrollTarget = null
userScrollIntent = 'down'
restoreFollowIfAtLatest()
}
} }
watch(projectId, id => { if (id) void load(id) }, { immediate: true }) watch(projectId, id => { if (id) void load(id) }, { immediate: true })
watch(() => events.value.length, async () => { watch(() => events.value.length, async () => {
const followsTail = !showBackToBottom.value
await nextTick() await nextTick()
if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight }) // nextTick 后重新读取实时状态,确保用户在本轮渲染期间向上滚动也能立即中止跟随。
if (followsLatestOutput.value) moveViewportToLatestOutput()
})
watch(outputContainer, (current, previous) => {
if (!outputResizeObserver) return
if (previous) outputResizeObserver.unobserve(previous)
if (current) outputResizeObserver.observe(current)
}, { flush: 'post' })
onMounted(() => {
/**
* Markstream 的平滑流式渲染会在 Vue nextTick 结束后继续逐帧增加内容高度。
* 监听真实输出容器的尺寸变化,才能在同一批事件的渲染动画期间持续贴住底部。
*/
outputResizeObserver = new ResizeObserver(() => {
if (followsLatestOutput.value) moveViewportToLatestOutput()
})
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
window.addEventListener('scroll', updateScrollState, { passive: true })
window.addEventListener('wheel', handleUserWheel, { passive: true })
window.addEventListener('mousedown', handleUserScrollbarDrag)
window.addEventListener('mousemove', handleUserScrollbarMove)
window.addEventListener('mouseup', handleUserMouseUp)
window.addEventListener('touchstart', handleUserTouchStart, { passive: true })
window.addEventListener('touchmove', handleUserTouchMove, { passive: true })
window.addEventListener('touchend', handleUserTouchEnd, { passive: true })
window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true })
window.addEventListener('keydown', handleUserKeydown)
updateScrollState()
}) })
onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true }))
onBeforeUnmount(() => { onBeforeUnmount(() => {
loadVersion++ loadVersion++
stopStream?.() stopStream?.()
outputResizeObserver?.disconnect()
outputResizeObserver = null
window.removeEventListener('scroll', updateScrollState) window.removeEventListener('scroll', updateScrollState)
window.removeEventListener('wheel', handleUserWheel)
window.removeEventListener('mousedown', handleUserScrollbarDrag)
window.removeEventListener('mousemove', handleUserScrollbarMove)
window.removeEventListener('mouseup', handleUserMouseUp)
window.removeEventListener('touchstart', handleUserTouchStart)
window.removeEventListener('touchmove', handleUserTouchMove)
window.removeEventListener('touchend', handleUserTouchEnd)
window.removeEventListener('touchcancel', handleUserTouchEnd)
window.removeEventListener('keydown', handleUserKeydown)
}) })
</script> </script>
@@ -368,59 +724,145 @@ onBeforeUnmount(() => {
<header class="page-header"> <header class="page-header">
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div> <div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
<div class="run-actions"> <div class="run-actions">
<el-button v-if="running" text :loading="modelPickerLoading" @click="openModelPicker('switch')">切换模型</el-button>
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button> <el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button> <el-button v-else-if="interrupted" type="primary" plain :loading="modelPickerLoading" @click="openModelPicker('resume')">继续</el-button>
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button> <el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
</div> </div>
</header> </header>
<div class="work-scroll"> <el-dialog v-model="modelPickerVisible" :title="modelPickerTitle" width="420px" :close-on-click-modal="false">
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section> <label class="model-picker-field">
<section v-else-if="!events.length" class="material-start"> <span>运行模型</span>
<h2>企业材料</h2> <el-select v-model="selectedModelConfigId" placeholder="选择模型" style="width: 100%">
<el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box"> <el-option
<el-icon><UploadFilled /></el-icon> v-for="model in selectableModels"
<p>拖入企业材料或点击上传</p> :key="model.id"
<small>支持 PDFDOCXXLSXPPTX图片</small> :label="`${model.name} · ${model.modelId}${model.defaultModel ? '(默认)' : ''}`"
</el-upload> :value="model.id"
<div class="folder-upload"> />
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button> </el-select>
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" /> </label>
</div> <template #footer>
<div v-if="files.length" class="file-chips"> <el-button :disabled="controlLoading" @click="modelPickerVisible = false">取消</el-button>
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span> <el-button type="primary" :loading="controlLoading" :disabled="!selectedModelConfigId" @click="confirmModelSelection">
<small v-if="files.length > 12"> {{ files.length }} 个文件</small> {{ modelPickerConfirmText }}
</div> </el-button>
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button> </template>
</section> </el-dialog>
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" /> <div class="project-workspace">
<MaterialAskCard <main class="workspace-main">
v-if="waitingMaterials" <ol class="project-stage-bar" aria-label="项目执行阶段">
:ask="pendingAsk as any" <li
:loading="loading" v-for="(stage, index) in projectStages"
:upload-file="uploadFile" :key="stage.key"
@confirm="confirmMaterials" class="project-stage"
/> :class="`is-${stage.state}`"
<PlanCard :aria-current="stage.state === 'current' ? 'step' : undefined"
v-if="waitingPlan" >
:plan-id="plan!.id" <span class="stage-marker" aria-hidden="true">{{ stage.state === 'complete' ? '✓' : index + 1 }}</span>
:plan="plan!.plan" <span class="stage-copy"><strong>{{ stage.label }}</strong><small>{{ stage.description }}</small></span>
:loading="loading" </li>
@confirm="confirmPlan" </ol>
/>
<section v-if="artifacts.length" class="artifact-card"> <div ref="outputContainer" class="work-scroll">
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row"> <section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
<Document /> <section v-else-if="!events.length" class="material-start">
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div> <h2>企业材料</h2>
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a> <el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box">
<el-icon><UploadFilled /></el-icon>
<p>拖入企业材料或点击上传</p>
<small>支持 PDFDOCXXLSXPPTX图片</small>
</el-upload>
<div class="folder-upload">
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button>
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" />
</div>
<div v-if="files.length" class="file-chips">
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span>
<small v-if="files.length > 12"> {{ files.length }} 个文件</small>
</div>
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button>
</section>
<!-- AgentTimeline 继续消费原始流式事件视觉布局不会替换或截断事件内容 -->
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" />
<MaterialAskCard
v-if="waitingMaterials"
:ask="pendingAsk as any"
:loading="loading"
:upload-file="uploadFile"
@confirm="confirmMaterials"
/>
<PlanCard
v-if="waitingPlan"
:plan-id="plan!.id"
:plan="plan!.plan"
:loading="loading"
@confirm="confirmPlan"
/>
<section v-if="artifacts.length" class="artifact-card">
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
<Document />
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div>
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a>
</div>
</section>
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断点击重连</button>
</div> </div>
</section> </main>
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断点击重连</button> <aside class="project-context-panel" aria-label="项目上下文">
<section class="context-card stage-context-card">
<h2>当前阶段</h2>
<div class="context-stage-summary">
<span class="context-stage-index">{{ currentStageIndex + 1 }}</span>
<div><strong>{{ currentStageLabel }}</strong><small>{{ statusLabel }}</small></div>
</div>
<dl class="context-definition-list">
<div><dt>最近更新</dt><dd>{{ updatedAtLabel }}</dd></div>
</dl>
</section>
<section class="context-card">
<div class="context-card-heading"><h2>当前模型</h2><span v-if="running" class="context-status">使用中</span></div>
<strong class="current-model-name">{{ currentModelLabel }}</strong>
</section>
<section class="context-card">
<div class="context-card-heading"><h2>企业材料</h2><span>{{ files.length }} 个文件</span></div>
<ul v-if="files.length" class="context-file-list">
<li v-for="file in files.slice(0, 4)" :key="file.id"><Document /><span :title="file.relativePath">{{ file.name }}</span></li>
</ul>
<p v-else class="context-empty">尚未上传材料</p>
<small v-if="files.length > 4" class="context-more">另有 {{ files.length - 4 }} 个文件</small>
</section>
<section class="context-card">
<div class="context-card-heading"><h2>生成文件</h2><span>{{ artifacts.length }} 个文件</span></div>
<ul v-if="artifacts.length" class="context-file-list artifact-context-list">
<li v-for="artifact in artifacts.slice(0, 3)" :key="artifact.id">
<Document />
<a :href="`/api/artifacts/${artifact.id}/download`" :title="artifact.name">{{ artifact.name }}</a>
</li>
</ul>
<p v-else class="context-empty">完成后将在这里显示交付物</p>
</section>
</aside>
</div> </div>
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom"></button> <button
v-if="!followsLatestOutput"
class="back-to-bottom"
type="button"
aria-label="转到最新输出"
title="转到最新输出"
@click="scrollToBottom"
>
<ArrowDown aria-hidden="true" />
</button>
</section> </section>
<section v-else class="empty-main">新建或选择一个项目</section> <section v-else class="empty-main">新建或选择一个项目</section>
</template> </template>

View File

@@ -1,13 +1,15 @@
:root { :root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #111827; color: #111827;
background: #fff; background: #f6f7f9;
font-synthesis: none; font-synthesis: none;
--blue: #0f5df5; --blue: #1769e8;
--blue-soft: #f2f7ff; --blue-soft: #eef5ff;
--muted: #667085; --muted: #667085;
--line: #e8edf5; --line: #e3e8ef;
--green: #087d41; --green: #087d41;
--surface: #fff;
--canvas: #f6f7f9;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -15,16 +17,16 @@ body { margin: 0; min-width: 0; min-height: 100vh; }
button, input, textarea { font: inherit; } button, input, textarea { font: inherit; }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; display: flex; background: #fff; } .app-shell { min-height: 100vh; display: flex; background: var(--canvas); }
.rail { width: 84px; flex: 0 0 84px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 10px; gap: 18px; } .rail { width: 76px; flex: 0 0 76px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 8px; gap: 16px; background: var(--surface); }
.brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; } .brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; }
.brand svg { width: 24px; } .brand svg { width: 24px; }
.rail a { width: 64px; height: 66px; border-radius: 9px; color: #405170; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 15px; } .rail a { width: 60px; height: 62px; border-radius: 8px; color: #526078; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 14px; }
.rail a svg { width: 24px; height: 24px; } .rail a svg { width: 24px; height: 24px; }
.rail a.active { background: var(--blue); color: #fff; } .rail a.active { background: var(--blue); color: #fff; }
.rail .brand + a { margin-top: 6px; } .rail .brand + a { margin-top: 6px; }
.project-list { width: 276px; flex: 0 0 276px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 16px; background: #fff; } .project-list { width: 264px; flex: 0 0 264px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 14px; background: var(--surface); }
.aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; } .aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
.aside-title h2 { font-size: 20px; margin: 0; } .aside-title h2 { font-size: 20px; margin: 0; }
.icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; } .icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; }
@@ -34,16 +36,53 @@ a { color: inherit; text-decoration: none; }
.project-item time { color: #697794; font-size: 13px; } .project-item time { color: #697794; font-size: 13px; }
.project-item.selected { background: #edf4ff; color: var(--blue); } .project-item.selected { background: #edf4ff; color: var(--blue); }
.aside-empty { padding: 20px 12px; color: #98a2b3; } .aside-empty { padding: 20px 12px; color: #98a2b3; }
.main-view { min-width: 0; flex: 1; } .main-view { min-width: 0; flex: 1; background: var(--surface); }
.project-page { min-height: 100vh; display: flex; flex-direction: column; } .project-page { min-height: 100vh; display: flex; flex-direction: column; background: var(--canvas); }
.page-header { height: 80px; flex: 0 0 80px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 32px; background: rgba(255,255,255,.95); position: sticky; top: 0; z-index: 3; } .page-header { height: 72px; flex: 0 0 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); position: sticky; top: 0; z-index: 3; }
.page-heading, .run-actions { display: flex; align-items: center; gap: 12px; } .page-heading, .run-actions { display: flex; align-items: center; gap: 12px; }
.page-header h1 { margin: 0; font-size: 25px; letter-spacing: -.02em; } .page-header h1 { margin: 0; font-size: 24px; letter-spacing: -.02em; }
.tag { display: inline-flex; align-items: center; height: 30px; padding: 0 11px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 14px; font-weight: 500; white-space: nowrap; } .tag { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 13px; font-weight: 500; white-space: nowrap; }
.tag.blue { color: var(--blue); border-color: #9dbdff; } .tag.blue { color: var(--blue); border-color: #9dbdff; }
.tag.green { color: var(--green); border-color: #9be0bd; } .tag.green { color: var(--green); border-color: #9be0bd; }
.work-scroll { width: min(920px, calc(100% - 64px)); margin: 0 auto; padding: 32px 0 72px; } .project-workspace { width: min(1280px, 100%); margin: 0 auto; padding: 24px; display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; align-items: start; }
.workspace-main { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
.project-stage-bar { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; padding: 20px 24px; border-bottom: 1px solid var(--line); list-style: none; }
.project-stage { min-width: 0; position: relative; display: flex; align-items: center; gap: 10px; color: #8a94a6; }
.project-stage:not(:last-child)::after { content: ""; height: 1px; position: absolute; top: 15px; left: 42px; right: 12px; background: #dce2eb; }
.project-stage.is-complete:not(:last-child)::after { background: #8fb6f4; }
.stage-marker { width: 30px; height: 30px; flex: 0 0 30px; position: relative; z-index: 1; display: grid; place-items: center; border: 1px solid #cfd7e4; border-radius: 50%; background: #f8fafc; color: #68758a; font-size: 13px; font-weight: 700; }
.project-stage.is-complete .stage-marker { border-color: #b8e0cc; background: #e9f7f0; color: var(--green); }
.project-stage.is-current .stage-marker { border-color: var(--blue); background: var(--blue); color: #fff; }
.stage-copy { min-width: 0; position: relative; z-index: 1; display: flex; flex-direction: column; gap: 3px; padding-right: 10px; background: var(--surface); }
.stage-copy strong { overflow: hidden; color: #4f5d73; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.stage-copy small { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.project-stage.is-current .stage-copy strong { color: #172033; }
.project-stage.is-complete .stage-copy strong { color: #344054; }
.work-scroll { width: min(900px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 72px; }
.project-context-panel { min-width: 0; position: sticky; top: 96px; display: grid; gap: 12px; }
.context-card { padding: 18px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
.context-card h2 { margin: 0; color: #253047; font-size: 15px; }
.context-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.context-card-heading > span, .context-status { color: #768399; font-size: 12px; white-space: nowrap; }
.context-status { padding: 3px 7px; border-radius: 4px; background: #e9f7f0; color: var(--green); }
.context-stage-summary { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.context-stage-index { width: 28px; height: 28px; flex: 0 0 28px; display: grid; place-items: center; border-radius: 50%; background: var(--blue); color: #fff; font-size: 13px; font-weight: 700; }
.context-stage-summary > div { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.context-stage-summary strong { color: #202b3f; font-size: 14px; }
.context-stage-summary small { color: var(--blue); font-size: 12px; }
.context-definition-list { margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line); }
.context-definition-list > div { display: flex; justify-content: space-between; gap: 12px; color: #748096; font-size: 12px; }
.context-definition-list dt, .context-definition-list dd { margin: 0; }
.context-definition-list dd { overflow: hidden; color: #47546a; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.current-model-name { display: block; overflow: hidden; color: #344054; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.context-file-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.context-file-list li { min-width: 0; display: flex; align-items: center; gap: 8px; color: #536078; font-size: 13px; }
.context-file-list svg { width: 16px; flex: 0 0 16px; color: #728099; }
.context-file-list span, .context-file-list a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-context-list a { color: var(--blue); }
.context-empty { margin: 0; color: #8a94a6; font-size: 13px; line-height: 1.6; }
.context-more { display: block; margin-top: 12px; color: #768399; }
.history-loading { margin-top: 24vh; text-align: center; color: var(--muted); } .history-loading { margin-top: 24vh; text-align: center; color: var(--muted); }
.material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; } .material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; }
@@ -59,11 +98,11 @@ a { color: inherit; text-decoration: none; }
.file-chips svg { width: 16px; color: var(--blue); } .file-chips svg { width: 16px; color: var(--blue); }
.file-chips > small { align-self: center; color: var(--muted); } .file-chips > small { align-self: center; color: var(--muted); }
.timeline { display: flex; flex-direction: column; gap: 12px; } .timeline { display: flex; flex-direction: column; gap: 10px; }
.load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; } .load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; }
.load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); } .load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); }
.flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; } .flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; }
.flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; } .flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
.flow-message > .flow-content { grid-column: 2; } .flow-message > .flow-content { grid-column: 2; }
.flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; } .flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; }
.agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; } .agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; }
@@ -89,7 +128,9 @@ a { color: inherit; text-decoration: none; }
.agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; } .agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; }
.agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; } .agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; }
.agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; } .agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
.activity-row { color: #778195; font-size: 13px; border: 0; } .activity-row { color: #6f7b90; font-size: 13px; border: 0; }
.flow-tool .activity-row { padding: 9px 12px; border: 1px solid var(--line); border-radius: 7px; background: #fafbfc; }
.flow-tool .activity-row summary { width: 100%; }
.activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; } .activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; }
.activity-row summary::-webkit-details-marker { display: none; } .activity-row summary::-webkit-details-marker { display: none; }
.activity-row summary::after { content: ""; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; } .activity-row summary::after { content: ""; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; }
@@ -107,15 +148,15 @@ a { color: inherit; text-decoration: none; }
.finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; } .finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
@keyframes activity-shimmer { to { background-position: -220% 0; } } @keyframes activity-shimmer { to { background-position: -220% 0; } }
@media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } } @media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } }
.notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-radius: 8px; background: #f6f9ff; } .notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border: 1px solid #dce5f2; border-radius: 8px; background: #f6f9ff; }
.notice-row svg { width: 18px; } .notice-row svg { width: 18px; }
.notice-row.success { color: #087d41; } .notice-row.success { color: #087d41; }
.notice-row.error { color: #c53131; background: #fff7f7; } .notice-row.error { color: #c53131; background: #fff7f7; }
.notice-row.info { color: #45658f; background: #f7f9fc; } .notice-row.info { color: #45658f; background: #f7f9fc; }
.pending { opacity: .9; } .pending { opacity: .9; }
.ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #f6f9ff; box-shadow: inset 0 0 0 1px #d7e4fb; } .ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #fffaf2; box-shadow: inset 0 0 0 1px #f1ca8b; }
.ask-card h3 { color: var(--blue); margin: 0 0 5px; font-size: 20px; } .ask-card h3 { color: #28354a; margin: 0 0 5px; font-size: 19px; }
.ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; } .ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; }
.plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; } .plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
.plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); } .plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); }
@@ -127,7 +168,7 @@ a { color: inherit; text-decoration: none; }
.plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; } .plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; }
.plan-note { display: block; margin-bottom: 16px; } .plan-note { display: block; margin-bottom: 16px; }
.plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; } .plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; }
.ask-actions { display: flex; gap: 8px; } .ask-actions { display: flex; justify-content: flex-end; gap: 8px; }
.material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; } .material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
.material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); } .material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.material-item:last-child { border-bottom: 0; } .material-item:last-child { border-bottom: 0; }
@@ -141,7 +182,7 @@ a { color: inherit; text-decoration: none; }
.material-clear { margin: 0; padding: 16px; color: var(--green); } .material-clear { margin: 0; padding: 16px; color: var(--green); }
.material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; } .material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; }
.material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; } .material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
.artifact-card { margin: 28px 0 min(28vh, 280px) 60px; border-radius: 8px; overflow: hidden; box-shadow: inset 0 0 0 1px #dce4ef; } .artifact-card { margin: 28px 0 72px 60px; border-radius: 8px; overflow: hidden; background: var(--surface); box-shadow: inset 0 0 0 1px #dce4ef; }
.artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); } .artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); }
.artifact-row:last-child { border: 0; } .artifact-row:last-child { border: 0; }
.artifact-row > svg { width: 25px; color: var(--blue); } .artifact-row > svg { width: 25px; color: var(--blue); }
@@ -150,30 +191,42 @@ a { color: inherit; text-decoration: none; }
.artifact-row a { color: var(--blue); } .artifact-row a { color: var(--blue); }
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; } .stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
.retry-button { display: block; margin: 28px auto 0; } .retry-button { display: block; margin: 28px auto 0; }
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; } .back-to-bottom { position: fixed; left: 50%; bottom: 28px; z-index: 4; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); transform: translateX(-50%); cursor: pointer; }
.back-to-bottom svg { width: 20px; height: 20px; }
.back-to-bottom:hover { background: #f4f7fb; }
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } .back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; } .empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
.model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }
.model-picker-field > span { color: #53617b; font-size: 14px; }
.settings-page { min-height: 100vh; padding: 28px 42px; } .settings-page { min-height: 100vh; padding: 28px 42px; }
.settings-page > header h1 { margin: 0; font-size: 28px; } .settings-page > header h1 { margin: 0; font-size: 28px; }
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; } .settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
.settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; } .settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); } .settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; } .settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
.settings-list h2 small { margin-left: 6px; color: #7b879b; font-size: 13px; font-weight: 500; }
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; } .settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
.settings-list button.selected { background: #edf4ff; color: var(--blue); } .settings-list button.selected { background: #edf4ff; color: var(--blue); }
.settings-list button.disabled strong, .settings-list button.disabled span { color: #8b96a8; }
.settings-list button strong, .settings-list button span { grid-column: 1; } .settings-list button strong, .settings-list button span { grid-column: 1; }
.settings-list button span { margin-top: 8px; color: #5f6d86; } .settings-list button span { margin-top: 8px; color: #5f6d86; }
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); } .settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
.settings-list button small.muted { color: #8b96a8; }
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; } .settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
.settings-list button small.muted i { background: #9ca6b5; }
.model-empty { min-height: 160px; display: grid; place-items: center; color: #8995a8; border: 1px dashed #d9e0ea; border-radius: 7px; }
.settings-form { padding-left: 28px; } .settings-form { padding-left: 28px; }
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); } .form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
.form-title h2 { margin: 0; font-size: 22px; } .form-title h2 { margin: 0; font-size: 22px; }
.model-title-actions { display: flex; align-items: center; gap: 10px; }
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); } .settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); } .capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
.capability-row div { display: flex; gap: 8px; } .capability-row div { display: flex; gap: 8px; }
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; } .capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; } .model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
.model-danger-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; } .connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
.connection-ok svg { width: 18px; } .connection-ok svg { width: 18px; }
@@ -212,8 +265,16 @@ a { color: inherit; text-decoration: none; }
.el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; } .el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; }
.el-dialog { border-radius: 12px; } .el-dialog { border-radius: 12px; }
@media (max-width: 1320px) {
.project-workspace { max-width: 960px; grid-template-columns: minmax(0, 1fr); }
.project-context-panel { display: none; }
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.project-list { width: 240px; flex-basis: 240px; } .project-list { width: 240px; flex-basis: 240px; }
.project-workspace { padding: 20px; }
.project-stage-bar { padding-right: 18px; padding-left: 18px; }
.stage-copy small { display: none; }
.settings-page { padding-left: 28px; padding-right: 28px; } .settings-page { padding-left: 28px; padding-right: 28px; }
.settings-grid { grid-template-columns: 310px 1fr; } .settings-grid { grid-template-columns: 310px 1fr; }
.skill-grid { grid-template-columns: 390px 1fr; } .skill-grid { grid-template-columns: 390px 1fr; }
@@ -226,9 +287,14 @@ a { color: inherit; text-decoration: none; }
.rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; } .rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; }
.rail a { width: 52px; font-size: 12px; } .rail a { width: 52px; font-size: 12px; }
.project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; } .project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; }
.project-workspace { padding: 12px; }
.workspace-main { border-radius: 7px; }
.project-stage-bar { grid-template-columns: repeat(4, minmax(112px, 1fr)); overflow-x: auto; padding: 16px; }
.project-stage:not(:last-child)::after { right: 8px; }
.work-scroll { width: calc(100% - 32px); } .work-scroll { width: calc(100% - 32px); }
.page-header { padding: 0 16px; } .page-header { padding: 0 16px; }
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; } .page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
.run-actions { gap: 2px; }
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; } .ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; } .flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
.flow-tool::before, .flow-reasoning::before { left: 7px; } .flow-tool::before, .flow-reasoning::before { left: 7px; }
@@ -236,10 +302,13 @@ a { color: inherit; text-decoration: none; }
.material-item { grid-template-columns: 1fr 160px; } .material-item { grid-template-columns: 1fr 160px; }
.material-upload { grid-column: 2; } .material-upload { grid-column: 2; }
.settings-page { padding: 24px 16px; } .settings-page { padding: 24px 16px; }
.settings-header { align-items: center; }
.settings-grid, .skill-grid { grid-template-columns: 1fr; } .settings-grid, .skill-grid { grid-template-columns: 1fr; }
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; } .settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
.settings-form, .skill-detail { padding: 24px 0 0; } .settings-form, .skill-detail { padding: 24px 0 0; }
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; } .settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
.model-actions { flex-wrap: wrap; }
.model-danger-actions { width: 100%; margin-left: 0; padding-top: 6px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173, port: 15173,
proxy: { proxy: {
'/api': 'http://127.0.0.1:8080' '/api': 'http://127.0.0.1:8080'
} }