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]
.env.*
!.env.example
deepseek_key.txt
test_data/
# 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。
deploy/*.tar

View File

@@ -11,23 +11,86 @@
## 本地启动
```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
mvn -q -f server/pom.xml spring-boot:run
npm --prefix client install
npm --prefix client run dev
npm --prefix web-ui install
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
mvn -q -f server/pom.xml test
npm --prefix client run test -- --run
npm --prefix client run build
npm --prefix web-ui run test
npm --prefix web-ui run build
docker compose -f docker-compose.yml config --quiet
```
产品、数据库与验收设计见 [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>
<java.version>21</java.version>
<agentscope.version>2.0.1</agentscope.version>
<mybatis-flex.version>1.11.8</mybatis-flex.version>
<tika.version>3.2.3</tika.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
@@ -40,6 +41,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
<version>${mybatis-flex.version}</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>

View File

@@ -1,6 +1,8 @@
package tech.easyflow.manuagent.agent;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
@@ -95,8 +97,27 @@ public class AgentController {
* @return 新恢复 Run
*/
@PostMapping("/runs/resume")
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) {
return runService.resume(projectId, principal);
public AgentRunService.RunView resume(
@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) {
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.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -17,6 +17,8 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
/**
* 持久化并查询项目级 AG-UI 事件。
@@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers;
@Service
public class AgentEventService {
private final JdbcClient jdbc;
private final AgentEventMapper eventMapper;
private final ObjectMapper objectMapper;
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
/**
* 创建事件服务。
*
* @param jdbc JDBC 客户端
* @param eventMapper Agent 事件 Mapper
* @param objectMapper JSON 映射器
*/
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
this.eventMapper = eventMapper;
this.objectMapper = objectMapper;
}
@@ -53,18 +55,14 @@ public class AgentEventService {
ObjectNode object = value.isObject()
? (ObjectNode) value
: objectMapper.createObjectNode().set("value", value);
EventView event = jdbc.sql("""
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
RETURNING id, project_id, run_id, event_type, payload, created_at
""")
.param("projectId", projectId)
.param("runId", runId)
.param("eventType", eventType)
.param("eventId", UUID.randomUUID().toString())
.param("payload", object.toString())
.query(this::mapEvent)
.single();
AgentEventEntity entity = new AgentEventEntity();
entity.setProjectId(projectId);
entity.setRunId(runId);
entity.setEventType(eventType);
entity.setEventId(UUID.randomUUID().toString());
entity.setPayloadJson(object.toString());
// 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
EventView event = toEventView(eventMapper.insertReturning(entity));
publishAfterCommit(event);
return event;
}
@@ -77,19 +75,24 @@ public class AgentEventService {
* @param limit 最大返回数量
* @return 有序事件
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
return jdbc.sql("""
SELECT id, project_id, run_id, event_type, payload, created_at
FROM app.agent_event
WHERE project_id = :projectId AND id > :afterId
ORDER BY id
LIMIT :limit
""")
.param("projectId", projectId)
.param("afterId", Math.max(0, afterId))
.param("limit", Math.clamp(limit, 1, 1000))
.query(this::mapEvent)
.list();
QueryWrapper query = QueryWrapper.create()
.select(
AgentEventEntity::getId,
AgentEventEntity::getProjectId,
AgentEventEntity::getRunId,
AgentEventEntity::getEventType,
AgentEventEntity::getPayloadJson,
AgentEventEntity::getCreatedAt)
.where(AgentEventEntity::getProjectId).eq(projectId)
.and(AgentEventEntity::getId).gt(Math.max(0, afterId))
.orderBy(AgentEventEntity::getId).asc()
.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 {
return new EventView(
rs.getLong("id"),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("event_type"),
objectMapper.readTree(rs.getString("payload")),
rs.getObject("created_at", OffsetDateTime.class));
entity.getId(),
entity.getProjectId(),
entity.getRunId(),
entity.getEventType(),
objectMapper.readTree(entity.getPayloadJson()),
entity.getCreatedAt());
} 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())
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
.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)
.takeUntilOther(stopSignal)
.bufferTimeout(64, Duration.ofMillis(120))

View File

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

View File

@@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/**
* 驱动材料检验、规划 Ask 和自动编写 Run。
@@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks;
public class AgentRunService {
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
private final JdbcClient jdbc;
private final AgentRunMapper runMapper;
private final ObjectMapper objectMapper;
private final AgentExecutionService executionService;
private final AgentOutputService outputService;
@@ -52,7 +52,7 @@ public class AgentRunService {
/**
* 创建 Agent Run 服务。
*
* @param jdbc JDBC 客户端
* @param runMapper Agent Run Mapper
* @param objectMapper JSON 映射器
* @param executionService Agent 执行服务
* @param outputService Agent 结构化输出服务
@@ -66,7 +66,7 @@ public class AgentRunService {
* @param transactions 编程式事务模板
*/
public AgentRunService(
JdbcClient jdbc,
AgentRunMapper runMapper,
ObjectMapper objectMapper,
AgentExecutionService executionService,
AgentOutputService outputService,
@@ -78,7 +78,7 @@ public class AgentRunService {
ArtifactService artifactService,
ExecutorService applicationExecutor,
TransactionTemplate transactions) {
this.jdbc = jdbc;
this.runMapper = runMapper;
this.objectMapper = objectMapper;
this.executionService = executionService;
this.outputService = outputService;
@@ -207,15 +207,7 @@ public class AgentRunService {
if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
}
int updated = jdbc.sql("""
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();
int updated = runMapper.interruptRunning(run.id());
requireTerminalUpdate(updated);
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
onCommit(() -> {
@@ -236,6 +228,19 @@ public class AgentRunService {
*/
@Transactional
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);
UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId);
@@ -243,18 +248,71 @@ public class AgentRunService {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
}
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);
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) {
case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true));
case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(projectId);
JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true));
}
case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(projectId);
ProjectService.PlanView plan = projectService.currentPlan(project.id());
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
}
@@ -263,7 +321,6 @@ public class AgentRunService {
default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
}
return run;
}
/**
@@ -473,15 +530,7 @@ public class AgentRunService {
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
transactions.executeWithoutResult(status -> {
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
int updated = jdbc.sql("""
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();
int updated = runMapper.waitForInput(runId, interrupt.toString());
requireTerminalUpdate(updated);
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
});
@@ -575,14 +624,7 @@ public class AgentRunService {
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
projectId, run.id(), run.startedAt().toInstant(), metadata);
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
int updated = jdbc.sql("""
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();
int updated = runMapper.completeRunning(run.id());
requireTerminalUpdate(updated);
projectService.updateStatus(projectId, "DELIVERED");
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
@@ -602,15 +644,7 @@ public class AgentRunService {
: "Agent 执行失败,请稍后重试";
try {
transactions.executeWithoutResult(status -> {
int updated = jdbc.sql("""
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();
int updated = runMapper.failRunning(run.id(), message);
if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
"code", "AGENT_RUN_FAILED", "message", message));
@@ -639,6 +673,7 @@ public class AgentRunService {
*
* @param id Run ID
* @param projectId 项目 ID
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
* @param triggerType 触发类型
* @param status 运行状态
* @param pendingInterrupt 待处理 Ask JSON
@@ -649,6 +684,7 @@ public class AgentRunService {
public record RunView(
UUID id,
UUID projectId,
UUID modelConfigId,
String triggerType,
String status,
String pendingInterrupt,

View File

@@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
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 持久化状态。
@@ -17,17 +21,27 @@ import org.springframework.stereotype.Service;
@Service
public class AgentRunStore {
private final JdbcClient jdbc;
private final AgentRunMapper runMapper;
private final AgentEventMapper eventMapper;
private final ModelConfigMapper modelMapper;
private final ObjectMapper objectMapper;
/**
* 创建 Run 状态存储。
*
* @param jdbc JDBC 客户端
* @param runMapper Agent Run Mapper
* @param eventMapper Agent 事件 Mapper
* @param modelMapper 模型配置 Mapper
* @param objectMapper JSON 映射器
*/
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
public AgentRunStore(
AgentRunMapper runMapper,
AgentEventMapper eventMapper,
ModelConfigMapper modelMapper,
ObjectMapper objectMapper) {
this.runMapper = runMapper;
this.eventMapper = eventMapper;
this.modelMapper = modelMapper;
this.objectMapper = objectMapper;
}
@@ -39,34 +53,63 @@ public class AgentRunStore {
* @param parentRunId 父 Run ID
* @return 新 Run
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
Integer active = jdbc.sql("""
SELECT count(*) FROM app.agent_run
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
""")
.param("projectId", projectId)
.query(Integer.class)
.single();
return create(projectId, triggerType, parentRunId, null);
}
/**
* 创建绑定指定模型的新 Run未指定模型时使用当前启用的默认模型。
*
* <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) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
}
UUID id = UUID.randomUUID();
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.single();
jdbc.sql("""
INSERT INTO app.agent_run(
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
""")
.param("id", id)
.param("projectId", projectId)
.param("parentRunId", parentRunId)
.param("modelId", modelId)
.param("triggerType", triggerType)
.param("traceId", UUID.randomUUID().toString())
.update();
return require(id);
QueryWrapper modelQuery = QueryWrapper.create()
.select(ModelConfigEntity::getId);
if (requestedModelId == null) {
modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
} else {
modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
}
modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
if (model == null) {
if (requestedModelId == null) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
}
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
}
// 应用层提前生成 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不存在时为空
*/
public AgentRunService.RunView latest(UUID projectId) {
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
.param("projectId", projectId)
.query(AgentRunStore::mapRun)
.optional()
.orElse(null);
QueryWrapper query = runViewQuery()
.where(AgentRunEntity::getProjectId).eq(projectId)
.orderBy(AgentRunEntity::getCreatedAt).desc()
.limit(1);
return toRunView(runMapper.selectOneByQuery(query));
}
/**
@@ -90,10 +133,14 @@ public class AgentRunStore {
* @return Run
*/
public AgentRunService.RunView require(UUID id) {
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
.param("id", id)
.query(AgentRunStore::mapRun)
.single();
QueryWrapper query = runViewQuery()
.where(AgentRunEntity::getId).eq(id);
AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
if (run == null) {
// 强制读取仅用于内部已知 ID缺失表示持久化状态异常而不是新增的 404 业务分支。
throw new IllegalStateException("Agent Run 不存在: " + id);
}
return run;
}
/**
@@ -102,13 +149,7 @@ public class AgentRunStore {
* @param runId Run ID
*/
public void completeWaiting(UUID runId) {
int updated = jdbc.sql("""
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();
int updated = runMapper.completeWaiting(runId);
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
}
@@ -142,10 +183,11 @@ public class AgentRunStore {
* @param runId Run ID
*/
public void ensureRunning(UUID runId) {
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(String.class)
.single();
String status = status(runId);
if (status == null) {
// 保持迁移前强制单条查询对缺失记录的未预期异常语义。
throw new IllegalStateException("Agent Run 不存在: " + runId);
}
if (!"RUNNING".equals(status)) {
throw new AgentExecutionService.RunInterruptedException();
}
@@ -158,11 +200,7 @@ public class AgentRunStore {
* @return 是否已停止
*/
public boolean isInterrupted(UUID runId) {
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(Boolean.class)
.optional()
.orElse(false);
return "INTERRUPTED".equals(status(runId));
}
/**
@@ -173,15 +211,8 @@ public class AgentRunStore {
* @return 业务阶段
*/
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
return jdbc.sql("""
SELECT payload ->> 'phase' FROM app.agent_event
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());
String phase = eventMapper.selectLatestStartedPhase(run.id());
return phase == null ? project.status() : phase;
}
/**
@@ -191,16 +222,10 @@ public class AgentRunStore {
* @return 材料确认 JSON
*/
public JsonNode latestMaterialResponse(UUID projectId) {
return jdbc.sql("""
SELECT payload::text FROM app.agent_event
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
AND jsonb_typeof(payload -> 'decisions') = 'array'
ORDER BY id DESC LIMIT 1
""")
.param("projectId", projectId)
.query(String.class)
.optional()
.map(value -> {
String value = eventMapper.selectLatestMaterialResponseJson(projectId);
if (value == null) {
return objectMapper.createObjectNode();
}
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException exception) {
@@ -209,25 +234,63 @@ public class AgentRunStore {
"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(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("trigger_type"),
rs.getString("status"),
rs.getString("pending_interrupt"),
rs.getString("error_message"),
rs.getObject("started_at", OffsetDateTime.class),
rs.getObject("ended_at", OffsetDateTime.class));
entity.getId(),
entity.getProjectId(),
entity.getModelConfigId(),
entity.getTriggerType(),
entity.getStatus(),
entity.getPendingInterrupt(),
entity.getErrorMessage(),
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
FROM app.agent_run
""";
/**
* 使用 BaseMapper 主键查询读取 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.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/**
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
@@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional;
@Order(0)
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) {
this.jdbc = jdbc;
public RunRecoveryService(AgentRunMapper runMapper) {
this.runMapper = runMapper;
}
/**
@@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner {
@Override
@Transactional
public void run(ApplicationArguments args) {
jdbc.sql("""
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();
runMapper.interruptRunningAfterRestart();
}
}

View File

@@ -1,6 +1,9 @@
package tech.easyflow.manuagent.artifact;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
@@ -18,7 +21,6 @@ import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
/**
@@ -27,19 +29,20 @@ import org.springframework.stereotype.Service;
@Service
public class ArtifactService {
private final JdbcClient jdbc;
private final ArtifactMapper artifactMapper;
private final ProjectFileService fileService;
private final DocxValidator docxValidator;
/**
* 创建产物服务。
*
* @param jdbc JDBC 客户端
* @param artifactMapper 产物 Mapper
* @param fileService 项目文件服务
* @param docxValidator DOCX 校验器
*/
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
this.jdbc = jdbc;
public ArtifactService(
ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
this.artifactMapper = artifactMapper;
this.fileService = fileService;
this.docxValidator = docxValidator;
}
@@ -118,36 +121,20 @@ public class ArtifactService {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
}
String hash = sha256(path);
UUID id = jdbc.sql("""
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type,
size_bytes, sha256, metadata_json)
VALUES (:id, :projectId, :runId, :kind, :name, :path,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
:size, :sha256, CAST(:metadata AS jsonb))
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
""")
.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);
// “项目 + 路径”必须原子 upsert避免先查后写在并发重试时触发唯一约束竞态。
ArtifactEntity entity = new ArtifactEntity();
entity.setId(UUID.randomUUID());
entity.setProjectId(projectId);
entity.setRunId(runId);
entity.setKind(kind);
entity.setName(name);
entity.setRelativePath(relativePath);
entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
entity.setSizeBytes(size);
entity.setSha256(hash);
entity.setMetadataJson(metadata.toString());
ArtifactEntity stored = artifactMapper.upsert(entity);
return toArtifactView(stored);
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
}
@@ -175,10 +162,12 @@ public class ArtifactService {
* @return 按发布时间倒序的产物
*/
public List<ArtifactView> list(UUID projectId) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
.param("projectId", projectId)
.query(ArtifactService::mapArtifact)
.list();
QueryWrapper query = artifactViewQuery()
.where(ArtifactEntity::getProjectId).eq(projectId)
.orderBy(ArtifactEntity::getPublishedAt).desc();
return artifactMapper.selectListByQuery(query).stream()
.map(ArtifactService::toArtifactView)
.toList();
}
/**
@@ -187,21 +176,28 @@ public class ArtifactService {
* @param artifactId 产物 ID
* @return 下载信息
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public Download download(UUID artifactId) {
StoredArtifact artifact = jdbc.sql("""
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
FROM app.artifact WHERE id = :id
""")
.param("id", artifactId)
.query((rs, rowNum) -> new StoredArtifact(
rs.getObject("project_id", UUID.class),
rs.getString("name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getLong("size_bytes"),
rs.getString("sha256")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
QueryWrapper query = QueryWrapper.create()
.select(
ArtifactEntity::getProjectId,
ArtifactEntity::getName,
ArtifactEntity::getRelativePath,
ArtifactEntity::getMimeType,
ArtifactEntity::getSizeBytes,
ArtifactEntity::getSha256)
.where(ArtifactEntity::getId).eq(artifactId);
ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
if (entity == null) {
throw 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 {
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
Resource resource = new UrlResource(path.toUri());
@@ -238,30 +234,44 @@ public class ArtifactService {
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)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
}
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
/**
* 将产物实体转换成接口稳定视图。
*
* @param entity 产物实体
* @return 产物接口视图
*/
private static ArtifactView toArtifactView(ArtifactEntity entity) {
return new ArtifactView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("kind"),
rs.getString("name"),
rs.getLong("size_bytes"),
rs.getString("metadata_json"),
rs.getObject("published_at", OffsetDateTime.class));
entity.getId(),
entity.getProjectId(),
entity.getRunId(),
entity.getKind(),
entity.getName(),
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
entity.getMetadataJson(),
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;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
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)
public class UserService implements UserDetailsService, ApplicationRunner {
private final JdbcClient jdbc;
private final AppUserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final AppProperties properties;
/**
* 创建用户服务。
*
* @param jdbc JDBC 客户端
* @param userMapper 用户表 Mapper
* @param passwordEncoder 密码编码器
* @param properties 应用配置
*/
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
this.jdbc = jdbc;
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
this.properties = properties;
}
@@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner {
*/
@Override
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
if (count == 0) {
jdbc.sql("""
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();
long count = userMapper.selectCountByQuery(QueryWrapper.create());
if (count > 0) {
return;
}
// 首次启动时仍由应用层生成 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 用户不存在或被禁用时抛出
*/
@Override
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
.param("username", username)
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
.password(rs.getString("password_hash"))
QueryWrapper query = QueryWrapper.create()
.select(
AppUserEntity::getUsername,
AppUserEntity::getPasswordHash,
AppUserEntity::getEnabled)
.where(AppUserEntity::getUsername).eq(username);
AppUserEntity entity = userMapper.selectOneByQuery(query);
if (entity == null) {
throw new UsernameNotFoundException("账户不存在");
}
return User.withUsername(entity.getUsername())
.password(entity.getPasswordHash())
.roles("ADMIN")
.disabled(!rs.getBoolean("enabled"))
.build())
.optional()
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
.disabled(!Boolean.TRUE.equals(entity.getEnabled()))
.build();
}
/**
@@ -87,11 +96,15 @@ public class UserService implements UserDetailsService, ApplicationRunner {
* @return 用户 UUID
* @throws ApiException 用户不存在时抛出
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public UUID requireUserId(String username) {
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
.param("username", username)
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
QueryWrapper query = QueryWrapper.create()
.select(AppUserEntity::getId)
.where(AppUserEntity::getUsername).eq(username);
AppUserEntity entity = userMapper.selectOneByQuery(query);
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 deepseekKeyFile DeepSeek Key 文件
* @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码
* @param modelBaseUrl 默认模型端点
* @param modelId 默认模型标识
* @param modelContextWindow 默认模型上下文窗口
* @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时
@@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app")
public record AppProperties(
Path dataRoot,
Path deepseekKeyFile,
Path dashscopeKeyFile,
String masterKey,
String adminUsername,
String adminPassword,
String modelBaseUrl,
String modelId,
int modelContextWindow,
String sandboxImage,
String sandboxNetwork,
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;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
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.PostMapping;
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
* @return 测试结果
@@ -93,4 +114,37 @@ public class ModelController {
public void setDefault(@PathVariable UUID id, Principal 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;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.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 java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import jakarta.validation.constraints.Size;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,89 +33,70 @@ import org.springframework.transaction.annotation.Transactional;
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/
@Service
@Order(2)
public class ModelService implements ApplicationRunner {
public class ModelService {
private final JdbcClient jdbc;
private final ModelConfigMapper modelMapper;
private final ModelAssignmentMapper assignmentMapper;
private final AgentRunMapper runMapper;
private final UserService userService;
private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 创建模型服务。
*
* @param jdbc JDBC 客户端
* @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
@Autowired
public ModelService(
JdbcClient jdbc,
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.keyCipher = keyCipher;
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
this(
modelMapper,
assignmentMapper,
runMapper,
userService,
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
@Transactional
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
return;
}
try {
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
if (key.isBlank()) {
return;
}
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
.query(UUID.class)
.single();
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);
}
ModelService(
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher,
ObjectMapper objectMapper,
HttpClient httpClient) {
this.modelMapper = modelMapper;
this.assignmentMapper = assignmentMapper;
this.runMapper = runMapper;
this.userService = userService;
this.keyCipher = keyCipher;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
/**
@@ -121,9 +105,12 @@ public class ModelService implements ApplicationRunner {
* @return 模型列表
*/
public List<ModelView> list() {
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
.query(ModelService::mapModel)
.list();
QueryWrapper query = modelViewQuery()
.orderBy(ModelConfigEntity::getDefaultModel).desc()
.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()) {
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();
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, created_by)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
""")
.param("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("userId", userId)
.update();
ModelConfigEntity model = editableModel(id, input);
model.setProvider("OPENAI_COMPATIBLE");
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
model.setApiKeyHint(hint(input.apiKey().trim()));
model.setKeyVersion((short) 1);
model.setDefaultModel(firstDefault);
model.setCreatedBy(userId);
modelMapper.insertModel(model);
if (firstDefault) {
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
upsertAssignment(role, id, userId);
}
}
} else {
int updated = input.apiKey() == null || input.apiKey().isBlank()
? jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
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("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();
ModelConfigEntity model = editableModel(id, input);
if (input.apiKey() != null && !input.apiKey().isBlank()) {
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
model.setApiKeyHint(hint(input.apiKey().trim()));
model.setKeyVersion((short) 1);
}
int updated = modelMapper.updateModel(model);
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
@@ -208,25 +169,64 @@ public class ModelService implements ApplicationRunner {
*/
@Transactional
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());
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
.param("id", id)
.update();
modelMapper.clearDefault();
if (modelMapper.setDefault(id) != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
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)
ON CONFLICT (role) DO UPDATE
SET model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
""")
.param("role", role)
.param("id", id)
.param("userId", userId)
.update();
upsertAssignment(role, id, userId);
}
}
/**
* 更新模型启用状态。
*
* <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
* 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
*
* @param id 模型 ID
* @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 测试结果
*/
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(
"model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
@@ -272,36 +305,59 @@ public class ModelService implements ApplicationRunner {
*
* @return 默认模型机密配置
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public ModelSecret defaultModelSecret() {
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
return requireSecret(id);
QueryWrapper query = QueryWrapper.create()
.select(
ModelConfigEntity::getId,
ModelConfigEntity::getBaseUrl,
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) {
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
.param("id", id)
.query(ModelService::mapModel)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
QueryWrapper query = modelViewQuery()
.where(ModelConfigEntity::getId).eq(id);
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
if (model == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
return toModelView(model);
}
private ModelSecret requireSecret(UUID id) {
return jdbc.sql("""
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
FROM app.model_config WHERE id = :id AND enabled
""")
.param("id", id)
.query((rs, rowNum) -> new ModelSecret(
rs.getObject("id", UUID.class),
rs.getString("base_url"),
rs.getString("model_id"),
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列LambdaGetter 可变参数不会引入运行期类型风险。
/**
* 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key仅供模型调用链使用。
*
* <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run
* 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
*
* @param id Run 绑定的模型配置 ID
* @return 可直接创建模型客户端的机密配置
*/
public ModelSecret requireRuntimeModel(UUID id) {
QueryWrapper query = QueryWrapper.create()
.select(
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(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("provider"),
rs.getString("base_url"),
rs.getString("model_id"),
rs.getString("api_key_hint"),
rs.getString("config_json"),
rs.getString("capabilities_json"),
rs.getBoolean("enabled"),
rs.getBoolean("is_default"),
rs.getObject("updated_at", OffsetDateTime.class));
model.getId(),
model.getName(),
model.getProvider(),
model.getBaseUrl(),
model.getModelId(),
model.getApiKeyHint(),
model.getConfigJson(),
model.getCapabilitiesJson(),
Boolean.TRUE.equals(model.getEnabled()),
Boolean.TRUE.equals(model.getDefaultModel()),
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) {
@@ -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) {
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("/")) {
value = value.substring(0, value.length() - 1);
}
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) {
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 能力声明
*/
public record ModelInput(
@NotBlank String name,
@NotBlank String baseUrl,
@NotBlank String modelId,
String apiKey,
@NotBlank @Size(max = 100) String name,
@NotBlank @Size(max = 500) String baseUrl,
@NotBlank @Size(max = 255) String modelId,
@Size(max = 4096) String apiKey,
Map<String, Object> config,
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;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import java.io.IOException;
import java.io.InputStream;
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.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -39,7 +41,7 @@ public class ProjectFileService {
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final JdbcClient jdbc;
private final ProjectFileMapper fileMapper;
private final UserService userService;
private final ProjectService projectService;
private final Path dataRoot;
@@ -48,17 +50,17 @@ public class ProjectFileService {
/**
* 创建材料服务。
*
* @param jdbc JDBC 客户端
* @param fileMapper 项目材料 Mapper
* @param userService 用户服务
* @param projectService 项目服务
* @param properties 应用配置
*/
public ProjectFileService(
JdbcClient jdbc,
ProjectFileMapper fileMapper,
UserService userService,
ProjectService projectService,
AppProperties properties) {
this.jdbc = jdbc;
this.fileMapper = fileMapper;
this.userService = userService;
this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
@@ -142,24 +144,19 @@ public class ProjectFileService {
Files.move(temporary, target);
moved = true;
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.project_file(
id, project_id, original_name, stored_name, relative_path, mime_type,
extension, size_bytes, sha256, uploaded_by)
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
:extension, :sizeBytes, :sha256, :userId)
""")
.param("id", fileId)
.param("projectId", projectId)
.param("originalName", originalName)
.param("storedName", target.getFileName().toString())
.param("relativePath", workspacePath)
.param("mimeType", mime)
.param("extension", extension)
.param("sizeBytes", Files.size(target))
.param("sha256", HexFormat.of().formatHex(digest.digest()))
.param("userId", userId)
.update();
// 数据库只保存受控路径和摘要selective insert 继续使用状态、时间字段的数据库默认值。
ProjectFileEntity entity = new ProjectFileEntity();
entity.setId(fileId);
entity.setProjectId(projectId);
entity.setOriginalName(originalName);
entity.setStoredName(target.getFileName().toString());
entity.setRelativePath(workspacePath);
entity.setMimeType(mime);
entity.setExtension(extension);
entity.setSizeBytes(Files.size(target));
entity.setSha256(HexFormat.of().formatHex(digest.digest()));
entity.setUploadedBy(userId);
fileMapper.insertSelective(entity);
return require(fileId);
} catch (FileAlreadyExistsException exception) {
cleanupFailedUpload(exception, temporary);
@@ -197,16 +194,13 @@ public class ProjectFileService {
*/
public List<FileView> list(UUID projectId) {
projectService.require(projectId);
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file
WHERE project_id = :projectId AND deleted_at IS NULL
ORDER BY relative_path
""")
.param("projectId", projectId)
.query(ProjectFileService::mapFile)
.list();
QueryWrapper query = fileViewQuery()
.where(ProjectFileEntity::getProjectId).eq(projectId)
.and(ProjectFileEntity::getDeletedAt).isNull()
.orderBy(ProjectFileEntity::getRelativePath).asc();
return fileMapper.selectListByQuery(query).stream()
.map(ProjectFileService::toFileView)
.toList();
}
/**
@@ -216,18 +210,22 @@ public class ProjectFileService {
* @param fileId 文件 ID
* @return 文件资源
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public Download download(UUID projectId, UUID fileId) {
StoredFile stored = jdbc.sql("""
SELECT original_name, relative_path, mime_type
FROM app.project_file
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
""")
.param("fileId", fileId)
.param("projectId", projectId)
.query((rs, rowNum) -> new StoredFile(
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
QueryWrapper query = QueryWrapper.create()
.select(
ProjectFileEntity::getOriginalName,
ProjectFileEntity::getRelativePath,
ProjectFileEntity::getMimeType)
.where(ProjectFileEntity::getId).eq(fileId)
.and(ProjectFileEntity::getProjectId).eq(projectId)
.and(ProjectFileEntity::getDeletedAt).isNull()
.and(ProjectFileEntity::getStatus).eq("READY");
ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
if (entity == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
}
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
try {
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
if (!resource.exists()) {
@@ -314,28 +312,54 @@ public class ProjectFileService {
}
private FileView require(UUID fileId) {
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file WHERE id = :id
""")
.param("id", fileId)
.query(ProjectFileService::mapFile)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
ProjectFileEntity entity = fileMapper.selectOneByQuery(
fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
if (entity == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
}
return toFileView(entity);
}
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(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("original_name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getString("extension"),
rs.getLong("size_bytes"),
rs.getString("status"),
rs.getObject("created_at", OffsetDateTime.class));
entity.getId(),
entity.getProjectId(),
entity.getOriginalName(),
entity.getRelativePath(),
entity.getMimeType(),
entity.getExtension(),
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
entity.getStatus(),
entity.getCreatedAt());
}
private String safeName(String originalName) {

View File

@@ -1,7 +1,12 @@
package tech.easyflow.manuagent.project;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ProjectEntity;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
import tech.easyflow.manuagent.mapper.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -10,7 +15,6 @@ import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class ProjectService {
private final JdbcClient jdbc;
private final ProjectMapper projectMapper;
private final ProjectPlanMapper planMapper;
private final UserService userService;
private final ObjectMapper objectMapper;
/**
* 创建项目服务。
*
* @param jdbc JDBC 客户端
* @param projectMapper 项目 Mapper
* @param planMapper 规划版本 Mapper
* @param userService 用户服务
* @param objectMapper JSON 映射器
*/
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
this.jdbc = jdbc;
public ProjectService(
ProjectMapper projectMapper,
ProjectPlanMapper planMapper,
UserService userService,
ObjectMapper objectMapper) {
this.projectMapper = projectMapper;
this.planMapper = planMapper;
this.userService = userService;
this.objectMapper = objectMapper;
}
@@ -51,18 +62,14 @@ public class ProjectService {
UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id;
jdbc.sql("""
INSERT INTO app.project(
id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
""")
.param("id", id)
.param("companyName", companyName.trim())
.param("projectName", companyName.trim())
.param("threadId", threadId)
.param("level", level)
.param("userId", userId)
.update();
ProjectEntity entity = new ProjectEntity();
entity.setId(id);
entity.setCompanyName(companyName.trim());
entity.setProjectName(companyName.trim());
entity.setAguiThreadId(threadId);
entity.setApplicationLevel(level);
entity.setCreatedBy(userId);
projectMapper.insertSelective(entity);
return require(id);
}
@@ -72,9 +79,10 @@ public class ProjectService {
* @return 按更新时间倒序的项目
*/
public List<ProjectView> list() {
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
.query(ProjectService::mapProject)
.list();
QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
return projectMapper.selectListByQuery(query).stream()
.map(ProjectService::toProjectView)
.toList();
}
/**
@@ -85,11 +93,12 @@ public class ProjectService {
* @throws ApiException 项目不存在时抛出
*/
public ProjectView require(UUID projectId) {
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
.param("id", projectId)
.query(ProjectService::mapProject)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
ProjectEntity entity = projectMapper.selectOneByQuery(
projectViewQuery().where(ProjectEntity::getId).eq(projectId));
if (entity == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
return toProjectView(entity);
}
/**
@@ -101,26 +110,17 @@ public class ProjectService {
@Transactional
public void delete(UUID projectId) {
require(projectId);
boolean running = jdbc.sql("""
SELECT EXISTS(
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
)
""")
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (running) {
if (projectMapper.hasRunningRun(projectId)) {
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
}
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
}
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
.param("projectId", projectId)
.update();
// 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
projectMapper.deleteEvents(projectId);
projectMapper.deleteArtifacts(projectId);
projectMapper.deletePlans(projectId);
projectMapper.deleteFiles(projectId);
projectMapper.deleteRuns(projectId);
int deleted = projectMapper.deleteById(projectId);
if (deleted != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
@@ -133,14 +133,7 @@ public class ProjectService {
* @param status 新阶段
*/
public void updateStatus(UUID projectId, String status) {
int updated = jdbc.sql("""
UPDATE app.project
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("status", status)
.param("id", projectId)
.update();
int updated = projectMapper.updateStatus(projectId, status);
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
@@ -156,23 +149,14 @@ public class ProjectService {
*/
@Transactional
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")
.param("id", projectId)
.query(Integer.class)
.single();
UUID planId = UUID.randomUUID();
jdbc.sql("""
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();
ProjectPlanEntity draft = new ProjectPlanEntity();
draft.setId(UUID.randomUUID());
draft.setProjectId(projectId);
draft.setPlanJson(plan.toString());
draft.setCreatedBy(userId);
ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
updateStatus(projectId, "PLANNING");
return requirePlan(planId);
return toPlanView(stored);
}
/**
@@ -182,17 +166,8 @@ public class ProjectService {
* @return 最新规划;不存在时返回空
*/
public PlanView currentPlan(UUID projectId) {
return jdbc.sql("""
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
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);
ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
return entity == null ? null : toPlanView(entity);
}
/**
@@ -207,61 +182,65 @@ public class ProjectService {
@Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
int updated = jdbc.sql("""
UPDATE app.project_plan
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) {
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
if (confirmed == null) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
}
updateStatus(projectId, "WRITING");
return requirePlan(planId);
return toPlanView(confirmed);
}
private PlanView requirePlan(UUID planId) {
return jdbc.sql("""
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 {
/** 将规划实体解析成包含 JsonNode 的接口视图。 */
private PlanView toPlanView(ProjectPlanEntity entity) {
try {
return new PlanView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getInt("plan_version"),
rs.getString("status"),
objectMapper.readTree(rs.getString("plan_json")),
rs.getObject("confirmed_at", OffsetDateTime.class),
rs.getObject("created_at", OffsetDateTime.class));
entity.getId(),
entity.getProjectId(),
entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
entity.getStatus(),
objectMapper.readTree(entity.getPlanJson()),
entity.getConfirmedAt(),
entity.getCreatedAt());
} 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(
rs.getObject("id", UUID.class),
rs.getString("company_name"),
rs.getString("project_name"),
rs.getString("agui_thread_id"),
rs.getString("application_level"),
rs.getString("status"),
rs.getLong("version"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class));
entity.getId(),
entity.getCompanyName(),
entity.getProjectName(),
entity.getAguiThreadId(),
entity.getApplicationLevel(),
entity.getStatus(),
entity.getVersion() == null ? 0L : entity.getVersion(),
entity.getCreatedAt(),
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) {
@@ -272,12 +251,6 @@ public class ProjectService {
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;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.SkillConfigEntity;
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
import tech.easyflow.manuagent.mapper.SkillViewRow;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException;
@@ -18,7 +22,6 @@ import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -32,7 +35,7 @@ public class SkillService {
private static final int MAX_ZIP_ENTRIES = 500;
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
private final JdbcClient jdbc;
private final SkillConfigMapper skillMapper;
private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader;
private final UserService userService;
@@ -41,19 +44,19 @@ public class SkillService {
/**
* 创建 Skill 服务。
*
* @param jdbc JDBC 客户端
* @param skillMapper 应用 Skill 配置 Mapper
* @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器
* @param userService 用户服务
* @param properties 应用配置
*/
public SkillService(
JdbcClient jdbc,
SkillConfigMapper skillMapper,
PostgresSkillRepository repository,
SkillPackageReader packageReader,
UserService userService,
AppProperties properties) {
this.jdbc = jdbc;
this.skillMapper = skillMapper;
this.repository = repository;
this.packageReader = packageReader;
this.userService = userService;
@@ -66,9 +69,9 @@ public class SkillService {
* @return Skill 列表
*/
public List<SkillView> list() {
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
.query(SkillService::mapSkill)
.list();
return skillMapper.selectViews().stream()
.map(SkillService::toSkillView)
.toList();
}
/**
@@ -78,11 +81,11 @@ public class SkillService {
* @return Skill 详情
*/
public SkillDetail require(String name) {
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
.param("name", name)
.query(SkillService::mapSkill)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
SkillViewRow row = skillMapper.selectView(name);
if (row == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
}
SkillView view = toSkillView(row);
AgentSkill skill = repository.getSkill(name);
if (skill == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
@@ -116,13 +119,7 @@ public class SkillService {
* @param enabled 是否启用
*/
public void setEnabled(String name, boolean enabled) {
int updated = jdbc.sql("""
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();
int updated = skillMapper.updateEnabled(name, enabled);
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
}
@@ -134,14 +131,15 @@ public class SkillService {
*
* @return Skill 名称数组
*/
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public String[] enabledNames() {
return jdbc.sql("""
SELECT skill_name FROM app.skill_config
WHERE enabled AND validation_status = 'VALID'
ORDER BY skill_name
""")
.query(String.class)
.list()
QueryWrapper query = QueryWrapper.create()
.select(SkillConfigEntity::getSkillName)
.where(SkillConfigEntity::getEnabled).eq(true)
.and(SkillConfigEntity::getValidationStatus).eq("VALID")
.orderBy(SkillConfigEntity::getSkillName).asc();
return skillMapper.selectListByQuery(query).stream()
.map(SkillConfigEntity::getSkillName)
.toArray(String[]::new);
}
@@ -171,17 +169,17 @@ public class SkillService {
}
repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.skill_config(
skill_name, version, source_type, enabled, read_only, checksum,
validation_status, imported_by)
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
""")
.param("name", name)
.param("version", skillPackage.version())
.param("checksum", skillPackage.checksum())
.param("userId", userId)
.update();
SkillConfigEntity config = new SkillConfigEntity();
config.setSkillName(name);
config.setVersion(skillPackage.version());
config.setSourceType("IMPORTED");
config.setEnabled(false);
config.setReadOnly(true);
config.setChecksum(skillPackage.checksum());
config.setValidationStatus("VALID");
config.setImportedBy(userId);
// Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
skillMapper.insertSelectiveWithPk(config);
return require(name).view();
} finally {
deleteTree(temporary);
@@ -197,13 +195,16 @@ public class SkillService {
* @param name Skill 名称
*/
@Transactional
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
public void deleteImported(String name) {
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
.param("name", name)
.query(String.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
if (!"IMPORTED".equals(sourceType)) {
QueryWrapper query = QueryWrapper.create()
.select(SkillConfigEntity::getSourceType)
.where(SkillConfigEntity::getSkillName).eq(name);
SkillConfigEntity config = skillMapper.selectOneByQuery(query);
if (config == null) {
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 不能删除");
}
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(
rs.getString("name"),
rs.getString("description"),
rs.getString("version"),
rs.getString("source_type"),
rs.getBoolean("enabled"),
rs.getBoolean("read_only"),
rs.getString("validation_status"),
rs.getString("validation_message"),
rs.getObject("updated_at", OffsetDateTime.class));
row.getName(),
row.getDescription(),
row.getVersion(),
row.getSourceType(),
Boolean.TRUE.equals(row.getEnabled()),
Boolean.TRUE.equals(row.getReadOnly()),
row.getValidationStatus(),
row.getValidationMessage(),
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 列表视图。
*

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:
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:
port: 8080
servlet:
@@ -32,14 +42,10 @@ server:
app:
data-root: file:../data
deepseek-key-file: ./deepseek_key.txt
dashscope-key-file: ./dashscope_key.txt
master-key: smart-factory-local-master-key
admin-username: admin
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-network: bridge
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;
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 tech.easyflow.manuagent.agent.AgentEventService;
import tech.easyflow.manuagent.agent.AgentRunService;
import tech.easyflow.manuagent.agent.AgentRunStore;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.artifact.DocxValidator;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.entity.AppUserEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ArtifactMapper;
import tech.easyflow.manuagent.mapper.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
import tech.easyflow.manuagent.model.KeyCipher;
import tech.easyflow.manuagent.model.ModelService;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.skill.SkillPackageReader;
import tech.easyflow.manuagent.skill.SkillService;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import 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.Path;
import java.io.InputStream;
import java.sql.DriverManager;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
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.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -77,7 +111,7 @@ class DatabaseAndEventIntegrationTest {
* 验证事件按项目全局 ID 增量回放且不重复。
*/
@Test
void shouldReplayEventsAfterCursorInOrder() {
void shouldReplayEventsAfterCursorInOrder() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
@@ -87,7 +121,7 @@ class DatabaseAndEventIntegrationTest {
.param("id", userId).param("name", "u-" + userId).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)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
""").param("id", modelId).param("name", "m-" + modelId).update();
jdbc.sql("""
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("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 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();
@@ -152,7 +186,7 @@ class DatabaseAndEventIntegrationTest {
Files.writeString(document, "first");
ProjectFileService files = mock(ProjectFileService.class);
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();
ArtifactService.ArtifactView first = artifacts.publish(
@@ -173,7 +207,7 @@ class DatabaseAndEventIntegrationTest {
* 验证项目真删除会清除所有关联业务记录。
*/
@Test
void shouldDeleteProjectRecords() {
void shouldDeleteProjectRecords() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
@@ -213,7 +247,8 @@ class DatabaseAndEventIntegrationTest {
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
.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);
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
@@ -229,11 +264,489 @@ class DatabaseAndEventIntegrationTest {
.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() {
return JdbcClient.create(dataSource());
}
private PGSimpleDataSource dataSource() {
PGSimpleDataSource source = new PGSimpleDataSource();
source.setURL(POSTGRES.getJdbcUrl());
source.setUser(POSTGRES.getUsername());
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() {
return new AppProperties(
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123", "https://api.example.test", "model",
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
Path.of("data"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123",
"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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* Run 在创建时已经固化模型配置 ID执行和模型重连都必须沿用该 ID
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
*/
@Test
void shouldCreateAgentWithModelBoundToRun() {
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentFactory factory = mock(AgentFactory.class);
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
SkillService skillService = mock(SkillService.class);
when(handle.adapter()).thenReturn(adapter);
when(adapter.run(any())).thenReturn(Flux.empty());
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
AgentExecutionService service = new AgentExecutionService(
new ObjectMapper(),
factory,
mock(AgentEventService.class),
mock(ProjectFileService.class),
skillService);
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("project-" + projectId);
AgentRunService.RunView run = new AgentRunService.RunView(
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null);
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
}
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/

View File

@@ -1,20 +1,87 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
/**
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
*/
@Test
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID currentRunId = UUID.randomUUID();
UUID replacementRunId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
ProjectService.ProjectView project = new ProjectService.ProjectView(
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
AgentRunService.RunView current = new AgentRunService.RunView(
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
AgentRunService.RunView replacement = new AgentRunService.RunView(
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunStore runStore = mock(AgentRunStore.class);
ProjectService projectService = mock(ProjectService.class);
UserService userService = mock(UserService.class);
when(projectService.require(projectId)).thenReturn(project);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
when(runStore.latest(projectId)).thenReturn(current);
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
AgentRunService service = new AgentRunService(
runMapper,
new ObjectMapper(),
mock(AgentExecutionService.class),
mock(AgentOutputService.class),
runStore,
mock(AgentEventService.class),
projectService,
mock(ProjectFileService.class),
userService,
mock(ArtifactService.class),
mock(ExecutorService.class),
mock(TransactionTemplate.class));
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
TransactionSynchronizationManager.initSynchronization();
try {
assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement);
verify(runMapper).interruptRunning(currentRunId);
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/

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.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 tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
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
void shouldDeleteProjectWorkspace() throws Exception {
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));
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
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();
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
Files.createDirectories(file.getParent());
@@ -72,17 +79,50 @@ class ProjectFileServiceTest {
.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 文件服务
*/
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(
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>智造申报 Agent</title>
</head>
<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">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleCheck } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
interface ModelConfig {
@@ -19,19 +19,38 @@ interface ModelConfig {
const models = ref<ModelConfig[]>([])
const selectedId = ref('')
const creating = ref(false)
const saving = ref(false)
const testing = ref(false)
const tested = ref(false)
const stateChanging = ref(false)
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
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')
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) {
selectedId.value = id
creating.value = false
const model = models.value.find(item => item.id === id)
if (!model) return
let capabilities: { contextWindow?: number } = {}
@@ -46,32 +65,57 @@ function select(id: string) {
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() {
if (!canSave.value || saving.value) return
saving.value = true
try {
await api(`/api/models/${selectedId.value}`, {
method: 'PUT',
const path = creating.value ? '/api/models' : `/api/models/${selectedId.value}`
const saved = await api<ModelConfig>(path, {
method: creating.value ? 'POST' : 'PUT',
body: JSON.stringify({
name: form.name,
baseUrl: form.baseUrl,
modelId: form.modelId,
name: form.name.trim(),
baseUrl: form.baseUrl.trim(),
modelId: form.modelId.trim(),
apiKey: form.apiKey,
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
})
})
ElMessage.success('已保存')
await load()
ElMessage.success(creating.value ? '模型已新增' : '配置已保存')
creating.value = false
await load(saved.id)
} finally {
saving.value = false
}
}
async function test() {
/**
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
* 测试只验证草稿,不会隐式保存任何配置字段。
*/
async function testConnection() {
if (!canTest.value || testing.value) return
testing.value = true
tested.value = false
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
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '连接失败')
@@ -80,43 +124,139 @@ async function test() {
}
}
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
async function setDefault() {
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' })
await load()
if (!selected.value || !selected.value.enabled || stateChanging.value) return
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>
<template>
<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">
<aside class="settings-list">
<h2>已配置模型</h2>
<h2>已配置模型 <small>{{ models.length }}</small></h2>
<div v-if="!models.length" class="model-empty">暂无模型</div>
<button
v-for="model in models"
:key="model.id"
:class="{ selected: selectedId === model.id }"
class="model-list-item"
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
@click="select(model.id)"
>
<strong>{{ model.name }}</strong>
<span>DeepSeek · {{ model.modelId }}</span>
<small><i></i>可用</small>
<span>OpenAI 兼容 · {{ model.modelId }}</span>
<small :class="{ muted: !model.enabled }">
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
</small>
</button>
</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>
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label>
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label>
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label>
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label>
<form v-if="creating || selected" class="settings-form" @submit.prevent="save">
<div class="form-title">
<h2>{{ creating ? '新增模型' : selected?.name }}</h2>
<div v-if="selected" class="model-title-actions">
<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>
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
<div class="model-actions">
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button>
<el-button :loading="testing" @click="test">测试连接</el-button>
<el-button native-type="submit" type="primary" :loading="saving" :disabled="!canSave">{{ creating ? '创建模型' : '保存配置' }}</el-button>
<el-button :loading="testing" :disabled="!canTest" @click="testConnection">测试连接</el-button>
<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>
</form>
</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 { useRoute, useRouter } from 'vue-router'
import { Document, UploadFilled } from '@element-plus/icons-vue'
import { ArrowDown } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AgentTimeline from '../components/AgentTimeline.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 { 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 router = useRouter()
const emit = defineEmits<{ 'projects-changed': [] }>()
@@ -26,36 +47,118 @@ const controlLoading = ref(false)
const deleting = ref(false)
const historyLoading = ref(true)
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 outputResizeObserver: ResizeObserver | null = null
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 LATEST_OUTPUT_THRESHOLD = 48
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
const projectId = computed(() => String(route.params.id || ''))
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
const running = computed(() => runStatus.value === 'RUNNING')
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 statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
}[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) {
const version = ++loadVersion
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 = null
project.value = null
events.value = []
try {
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<ProjectFile[]>(`/api/projects/${id}/files`),
api<Artifact[]>(`/api/projects/${id}/artifacts`),
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)
if (version !== loadVersion || id !== projectId.value) return
@@ -65,6 +168,8 @@ async function load(id: string) {
plan.value = loadedPlan
events.value = loadedEvents
runStatus.value = latest?.status || ''
currentModelConfigId.value = latest?.modelConfigId || ''
modelCatalog.value = loadedModels
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
startStream(id, version)
} finally {
@@ -196,8 +301,8 @@ async function startCheck() {
if (loading.value || folderUploading.value) return
loading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
runStatus.value = run.status
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
syncRunState(run)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '启动失败')
} finally {
@@ -209,8 +314,8 @@ async function retry() {
if (plan.value?.status === 'CONFIRMED') {
loading.value = true
try {
await api(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
runStatus.value = 'RUNNING'
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
syncRunState(run)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重试失败')
} finally {
@@ -225,8 +330,8 @@ async function stopRun() {
if (!running.value || controlLoading.value) return
controlLoading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
runStatus.value = run.status
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
syncRunState(run)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败')
} 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
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' })
runStatus.value = run.status
const endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
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) {
ElMessage.error(error instanceof Error ? error.message : '继续失败')
const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
ElMessage.error(error instanceof Error ? error.message : fallback)
} finally {
controlLoading.value = false
}
@@ -289,14 +431,14 @@ async function confirmPlan(value: Record<string, unknown>) {
if (!plan.value || loading.value) return
loading.value = true
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',
body: JSON.stringify({ planId: plan.value.id, plan: value })
})
localStorage.removeItem(`plan-draft:${plan.value.id}`)
plan.value = result.plan
pendingAsk.value = null
runStatus.value = result.run.status
syncRunState(result.run)
await refreshProject()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
@@ -309,12 +451,12 @@ async function confirmMaterials(value: Record<string, unknown>) {
if (loading.value) return
loading.value = true
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)
})
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
pendingAsk.value = null
runStatus.value = run.status
syncRunState(run)
await refreshProject()
} catch (error) {
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
}
/**
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
*/
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() {
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(() => events.value.length, async () => {
const followsTail = !showBackToBottom.value
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(() => {
loadVersion++
stopStream?.()
outputResizeObserver?.disconnect()
outputResizeObserver = null
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>
@@ -368,13 +724,49 @@ onBeforeUnmount(() => {
<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="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-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>
</div>
</header>
<div class="work-scroll">
<el-dialog v-model="modelPickerVisible" :title="modelPickerTitle" width="420px" :close-on-click-modal="false">
<label class="model-picker-field">
<span>运行模型</span>
<el-select v-model="selectedModelConfigId" placeholder="选择模型" style="width: 100%">
<el-option
v-for="model in selectableModels"
:key="model.id"
:label="`${model.name} · ${model.modelId}${model.defaultModel ? '(默认)' : ''}`"
:value="model.id"
/>
</el-select>
</label>
<template #footer>
<el-button :disabled="controlLoading" @click="modelPickerVisible = false">取消</el-button>
<el-button type="primary" :loading="controlLoading" :disabled="!selectedModelConfigId" @click="confirmModelSelection">
{{ modelPickerConfirmText }}
</el-button>
</template>
</el-dialog>
<div class="project-workspace">
<main class="workspace-main">
<ol class="project-stage-bar" aria-label="项目执行阶段">
<li
v-for="(stage, index) in projectStages"
:key="stage.key"
class="project-stage"
:class="`is-${stage.state}`"
:aria-current="stage.state === 'current' ? 'step' : undefined"
>
<span class="stage-marker" aria-hidden="true">{{ stage.state === 'complete' ? '✓' : index + 1 }}</span>
<span class="stage-copy"><strong>{{ stage.label }}</strong><small>{{ stage.description }}</small></span>
</li>
</ol>
<div ref="outputContainer" class="work-scroll">
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
<section v-else-if="!events.length" class="material-start">
<h2>企业材料</h2>
@@ -394,6 +786,7 @@ onBeforeUnmount(() => {
<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"
@@ -420,7 +813,56 @@ onBeforeUnmount(() => {
<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>
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom"></button>
</main>
<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>
<button
v-if="!followsLatestOutput"
class="back-to-bottom"
type="button"
aria-label="转到最新输出"
title="转到最新输出"
@click="scrollToBottom"
>
<ArrowDown aria-hidden="true" />
</button>
</section>
<section v-else class="empty-main">新建或选择一个项目</section>
</template>

View File

@@ -1,13 +1,15 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #111827;
background: #fff;
background: #f6f7f9;
font-synthesis: none;
--blue: #0f5df5;
--blue-soft: #f2f7ff;
--blue: #1769e8;
--blue-soft: #eef5ff;
--muted: #667085;
--line: #e8edf5;
--line: #e3e8ef;
--green: #087d41;
--surface: #fff;
--canvas: #f6f7f9;
}
* { box-sizing: border-box; }
@@ -15,16 +17,16 @@ body { margin: 0; min-width: 0; min-height: 100vh; }
button, input, textarea { font: inherit; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; display: flex; background: #fff; }
.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; }
.app-shell { min-height: 100vh; display: flex; background: var(--canvas); }
.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 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.active { background: var(--blue); color: #fff; }
.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 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; }
@@ -34,16 +36,53 @@ a { color: inherit; text-decoration: none; }
.project-item time { color: #697794; font-size: 13px; }
.project-item.selected { background: #edf4ff; color: var(--blue); }
.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; }
.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; }
.project-page { min-height: 100vh; display: flex; flex-direction: column; background: var(--canvas); }
.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-header h1 { margin: 0; font-size: 25px; 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; }
.page-header h1 { margin: 0; font-size: 24px; letter-spacing: -.02em; }
.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.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); }
.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 > 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:hover, .load-earlier:focus-visible { color: var(--primary); }
.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-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; }
@@ -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 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; }
.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::-webkit-details-marker { display: none; }
.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; }
@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; } }
.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.success { color: #087d41; }
.notice-row.error { color: #c53131; background: #fff7f7; }
.notice-row.info { color: #45658f; background: #f7f9fc; }
.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 h3 { color: var(--blue); margin: 0 0 5px; font-size: 20px; }
.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: #28354a; margin: 0 0 5px; font-size: 19px; }
.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 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-note { display: block; margin-bottom: 16px; }
.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-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; }
@@ -141,7 +182,7 @@ a { color: inherit; text-decoration: none; }
.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-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:last-child { border: 0; }
.artifact-row > svg { width: 25px; color: var(--blue); }
@@ -150,30 +191,42 @@ a { color: inherit; text-decoration: none; }
.artifact-row a { color: var(--blue); }
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
.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; }
.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 > header h1 { margin: 0; font-size: 28px; }
.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-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 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.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 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.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 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; }
.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; }
.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); }
.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 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-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 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-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) {
.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-grid { grid-template-columns: 310px 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 a { width: 52px; font-size: 12px; }
.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); }
.page-header { padding: 0 16px; }
.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; }
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
.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-upload { grid-column: 2; }
.settings-page { padding: 24px 16px; }
.settings-header { align-items: center; }
.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-form, .skill-detail { padding: 24px 0 0; }
.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) {

View File

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