Compare commits
9 Commits
c13302c0cb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d392436620 | ||
|
|
8c3883e356 | ||
|
|
4615df3f90 | ||
|
|
595c8da595 | ||
|
|
99d16bb3b9 | ||
|
|
7055f62da1 | ||
|
|
4759759d02 | ||
|
|
988c0fe555 | ||
|
|
e968a8ddc1 |
22
.env.example
Normal file
22
.env.example
Normal 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
6
.gitattributes
vendored
Normal 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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -14,4 +14,8 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
test_data/
|
deepseek_key.txt
|
||||||
|
test_data/
|
||||||
|
|
||||||
|
# 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。
|
||||||
|
deploy/*.tar
|
||||||
|
|||||||
77
README.md
77
README.md
@@ -11,23 +11,86 @@
|
|||||||
## 本地启动
|
## 本地启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile build-only build agent-runtime
|
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
mvn -q -f server/pom.xml spring-boot:run
|
mvn -q -f server/pom.xml spring-boot:run
|
||||||
npm --prefix client install
|
npm --prefix web-ui install
|
||||||
npm --prefix client run dev
|
npm --prefix web-ui run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123`。
|
本地开发服务器默认使用 Vite 配置的端口。容器化部署固定从 <http://127.0.0.1:5173> 访问。
|
||||||
|
|
||||||
项目根目录的 `deepseek_key.txt` 和 `dashscope_key.txt` 分别供模型与百炼知识库使用;模型、Skill 也可在页面内查看或配置。
|
模型连接只能在“模型配置”页面新增并持久化到 PostgreSQL;API Key 会使用 `APP_MASTER_KEY`
|
||||||
|
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
|
||||||
|
|
||||||
|
启动后端前必须设置模型密钥加密主密钥:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose 部署
|
||||||
|
|
||||||
|
部署包含 Nginx 前端、Spring Boot 后端、Agent Runtime 镜像和 PostgreSQL。Agent Runtime
|
||||||
|
不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `agent-runtime` 服务只校验
|
||||||
|
镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。
|
||||||
|
|
||||||
|
1. 在开发机构建并导出 Agent Runtime 镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox
|
||||||
|
docker save -o smart-factory-agent-runtime-0.1.0.tar smart-factory-agent-runtime:0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
将 Tar 文件传到服务器后导入。Compose 设置了 `pull_policy: never`,若镜像不存在会直接报错,
|
||||||
|
不会在服务器自动拉取或构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load -i smart-factory-agent-runtime-0.1.0.tar
|
||||||
|
docker image inspect smart-factory-agent-runtime:0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 确认 `MANUAGENT_DATA_ROOT` 是 Docker 宿主机上的绝对路径。这个路径会以完全相同的路径挂载到
|
||||||
|
后端容器,供动态 Agent Runtime 继续挂载项目材料、工作目录和产物。Linux 默认值为
|
||||||
|
`/srv/manuagent/data`。
|
||||||
|
|
||||||
|
4. 构建前后端并启动完整服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d --build
|
||||||
|
docker compose -f docker-compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 打开 <http://127.0.0.1:5173>,使用 `.env` 中的 `APP_ADMIN_USERNAME` 和
|
||||||
|
`APP_ADMIN_PASSWORD` 登录。查看日志或停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml logs -f backend
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173,可仅在本机
|
||||||
|
验证时临时执行 `$env:FRONTEND_PORT = '15173'`;Linux 服务器部署应保留默认的 5173。
|
||||||
|
|
||||||
|
PostgreSQL 数据保存在 `postgres-data` 命名卷中;项目材料、Agent 工作区、快照和生成文件保存在
|
||||||
|
`MANUAGENT_DATA_ROOT`。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除
|
||||||
|
PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的
|
||||||
|
Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将
|
||||||
|
`SESSION_COOKIE_SECURE` 设为 `true`。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn -q -f server/pom.xml test
|
mvn -q -f server/pom.xml test
|
||||||
npm --prefix client run test -- --run
|
npm --prefix web-ui run test
|
||||||
npm --prefix client run build
|
npm --prefix web-ui run build
|
||||||
|
docker compose -f docker-compose.yml config --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。
|
产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。
|
||||||
|
|||||||
25
compose.yml
25
compose.yml
@@ -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:
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
sk-8d1419754bce4306bc99854ee5ccd505
|
|
||||||
22
deploy/.env.example
Normal file
22
deploy/.env.example
Normal 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
130
deploy/docker-compose.yml
Normal 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
130
docker-compose.yml
Normal 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
6
server/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
target
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
37
server/Dockerfile
Normal file
37
server/Dockerfile
Normal 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"]
|
||||||
25
server/docker-entrypoint.sh
Normal file
25
server/docker-entrypoint.sh
Normal 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 "$@"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<agentscope.version>2.0.1</agentscope.version>
|
<agentscope.version>2.0.1</agentscope.version>
|
||||||
|
<mybatis-flex.version>1.11.8</mybatis-flex.version>
|
||||||
<tika.version>3.2.3</tika.version>
|
<tika.version>3.2.3</tika.version>
|
||||||
<testcontainers.version>1.21.4</testcontainers.version>
|
<testcontainers.version>1.21.4</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mybatis-flex</groupId>
|
||||||
|
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-flex.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.manuagent.agent;
|
package tech.easyflow.manuagent.agent;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -95,8 +97,27 @@ public class AgentController {
|
|||||||
* @return 新恢复 Run
|
* @return 新恢复 Run
|
||||||
*/
|
*/
|
||||||
@PostMapping("/runs/resume")
|
@PostMapping("/runs/resume")
|
||||||
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) {
|
public AgentRunService.RunView resume(
|
||||||
return runService.resume(projectId, principal);
|
@PathVariable UUID projectId,
|
||||||
|
@Valid @RequestBody(required = false) ResumeInput input,
|
||||||
|
Principal principal) {
|
||||||
|
return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将运行中的任务受控切换到替代模型。
|
||||||
|
*
|
||||||
|
* @param projectId 项目 ID
|
||||||
|
* @param input 替代模型
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 绑定替代模型的新恢复 Run
|
||||||
|
*/
|
||||||
|
@PostMapping("/runs/switch-model")
|
||||||
|
public AgentRunService.RunView switchModel(
|
||||||
|
@PathVariable UUID projectId,
|
||||||
|
@Valid @RequestBody ResumeInput input,
|
||||||
|
Principal principal) {
|
||||||
|
return runService.switchModel(projectId, input.modelConfigId(), principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,4 +159,12 @@ public class AgentController {
|
|||||||
@RequestParam(defaultValue = "0") long after) {
|
@RequestParam(defaultValue = "0") long after) {
|
||||||
return eventService.streamAfter(projectId, after);
|
return eventService.streamAfter(projectId, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复或切换任务时指定的模型。
|
||||||
|
*
|
||||||
|
* @param modelConfigId 目标模型配置 ID
|
||||||
|
*/
|
||||||
|
public record ResumeInput(@NotNull UUID modelConfigId) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package tech.easyflow.manuagent.agent;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
@@ -17,6 +17,8 @@ import reactor.core.publisher.Flux;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.publisher.Sinks;
|
import reactor.core.publisher.Sinks;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 持久化并查询项目级 AG-UI 事件。
|
* 持久化并查询项目级 AG-UI 事件。
|
||||||
@@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers;
|
|||||||
@Service
|
@Service
|
||||||
public class AgentEventService {
|
public class AgentEventService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AgentEventMapper eventMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建事件服务。
|
* 创建事件服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param eventMapper Agent 事件 Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
|
public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
|
||||||
this.jdbc = jdbc;
|
this.eventMapper = eventMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,18 +55,14 @@ public class AgentEventService {
|
|||||||
ObjectNode object = value.isObject()
|
ObjectNode object = value.isObject()
|
||||||
? (ObjectNode) value
|
? (ObjectNode) value
|
||||||
: objectMapper.createObjectNode().set("value", value);
|
: objectMapper.createObjectNode().set("value", value);
|
||||||
EventView event = jdbc.sql("""
|
AgentEventEntity entity = new AgentEventEntity();
|
||||||
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
|
entity.setRunId(runId);
|
||||||
RETURNING id, project_id, run_id, event_type, payload, created_at
|
entity.setEventType(eventType);
|
||||||
""")
|
entity.setEventId(UUID.randomUUID().toString());
|
||||||
.param("projectId", projectId)
|
entity.setPayloadJson(object.toString());
|
||||||
.param("runId", runId)
|
// 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
|
||||||
.param("eventType", eventType)
|
EventView event = toEventView(eventMapper.insertReturning(entity));
|
||||||
.param("eventId", UUID.randomUUID().toString())
|
|
||||||
.param("payload", object.toString())
|
|
||||||
.query(this::mapEvent)
|
|
||||||
.single();
|
|
||||||
publishAfterCommit(event);
|
publishAfterCommit(event);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -77,19 +75,24 @@ public class AgentEventService {
|
|||||||
* @param limit 最大返回数量
|
* @param limit 最大返回数量
|
||||||
* @return 有序事件
|
* @return 有序事件
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
||||||
return jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT id, project_id, run_id, event_type, payload, created_at
|
.select(
|
||||||
FROM app.agent_event
|
AgentEventEntity::getId,
|
||||||
WHERE project_id = :projectId AND id > :afterId
|
AgentEventEntity::getProjectId,
|
||||||
ORDER BY id
|
AgentEventEntity::getRunId,
|
||||||
LIMIT :limit
|
AgentEventEntity::getEventType,
|
||||||
""")
|
AgentEventEntity::getPayloadJson,
|
||||||
.param("projectId", projectId)
|
AgentEventEntity::getCreatedAt)
|
||||||
.param("afterId", Math.max(0, afterId))
|
.where(AgentEventEntity::getProjectId).eq(projectId)
|
||||||
.param("limit", Math.clamp(limit, 1, 1000))
|
.and(AgentEventEntity::getId).gt(Math.max(0, afterId))
|
||||||
.query(this::mapEvent)
|
.orderBy(AgentEventEntity::getId).asc()
|
||||||
.list();
|
.limit(Math.clamp(limit, 1, 1000));
|
||||||
|
return eventMapper.selectListByQuery(query)
|
||||||
|
.stream()
|
||||||
|
.map(this::toEventView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,17 +160,23 @@ public class AgentEventService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/**
|
||||||
|
* 将持久化实体转换成对外事件视图,并在边界处解析 JSONB 文本。
|
||||||
|
*
|
||||||
|
* @param entity 数据库事件实体
|
||||||
|
* @return 可供 REST 与事件流输出的事件
|
||||||
|
*/
|
||||||
|
private EventView toEventView(AgentEventEntity entity) {
|
||||||
try {
|
try {
|
||||||
return new EventView(
|
return new EventView(
|
||||||
rs.getLong("id"),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getObject("run_id", UUID.class),
|
entity.getRunId(),
|
||||||
rs.getString("event_type"),
|
entity.getEventType(),
|
||||||
objectMapper.readTree(rs.getString("payload")),
|
objectMapper.readTree(entity.getPayloadJson()),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||||
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception);
|
throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public class AgentExecutionService {
|
|||||||
.runId(run.id().toString())
|
.runId(run.id().toString())
|
||||||
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
|
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
|
||||||
.build();
|
.build();
|
||||||
try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) {
|
try (AgentFactory.AgentHandle handle = agentFactory.create(
|
||||||
|
project.id(), run.modelConfigId(), skillService.enabledNames())) {
|
||||||
handle.adapter().run(input)
|
handle.adapter().run(input)
|
||||||
.takeUntilOther(stopSignal)
|
.takeUntilOther(stopSignal)
|
||||||
.bufferTimeout(64, Duration.ofMillis(120))
|
.bufferTimeout(64, Duration.ofMillis(120))
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ public class AgentFactory {
|
|||||||
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
|
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
|
||||||
*
|
*
|
||||||
* @param projectId 项目 ID
|
* @param projectId 项目 ID
|
||||||
|
* @param modelConfigId Run 创建时绑定的模型配置 ID
|
||||||
* @param enabledSkills 当前启用 Skill
|
* @param enabledSkills 当前启用 Skill
|
||||||
* @return 需要在流结束后关闭的 Agent 句柄
|
* @return 需要在流结束后关闭的 Agent 句柄
|
||||||
*/
|
*/
|
||||||
public AgentHandle create(UUID projectId, String[] enabledSkills) {
|
public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) {
|
||||||
ModelService.ModelSecret model = modelService.defaultModelSecret();
|
// 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置;全局默认模型只参与新 Run 的选择。
|
||||||
|
ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId);
|
||||||
OpenAIChatModel chatModel = OpenAIChatModel.builder()
|
OpenAIChatModel chatModel = OpenAIChatModel.builder()
|
||||||
.apiKey(model.apiKey())
|
.apiKey(model.apiKey())
|
||||||
.baseUrl(model.baseUrl())
|
.baseUrl(model.baseUrl())
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
import reactor.core.publisher.Sinks;
|
import reactor.core.publisher.Sinks;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
||||||
@@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks;
|
|||||||
public class AgentRunService {
|
public class AgentRunService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
||||||
private final JdbcClient jdbc;
|
private final AgentRunMapper runMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AgentExecutionService executionService;
|
private final AgentExecutionService executionService;
|
||||||
private final AgentOutputService outputService;
|
private final AgentOutputService outputService;
|
||||||
@@ -52,7 +52,7 @@ public class AgentRunService {
|
|||||||
/**
|
/**
|
||||||
* 创建 Agent Run 服务。
|
* 创建 Agent Run 服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param runMapper Agent Run Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
* @param executionService Agent 执行服务
|
* @param executionService Agent 执行服务
|
||||||
* @param outputService Agent 结构化输出服务
|
* @param outputService Agent 结构化输出服务
|
||||||
@@ -66,7 +66,7 @@ public class AgentRunService {
|
|||||||
* @param transactions 编程式事务模板
|
* @param transactions 编程式事务模板
|
||||||
*/
|
*/
|
||||||
public AgentRunService(
|
public AgentRunService(
|
||||||
JdbcClient jdbc,
|
AgentRunMapper runMapper,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
AgentExecutionService executionService,
|
AgentExecutionService executionService,
|
||||||
AgentOutputService outputService,
|
AgentOutputService outputService,
|
||||||
@@ -78,7 +78,7 @@ public class AgentRunService {
|
|||||||
ArtifactService artifactService,
|
ArtifactService artifactService,
|
||||||
ExecutorService applicationExecutor,
|
ExecutorService applicationExecutor,
|
||||||
TransactionTemplate transactions) {
|
TransactionTemplate transactions) {
|
||||||
this.jdbc = jdbc;
|
this.runMapper = runMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.executionService = executionService;
|
this.executionService = executionService;
|
||||||
this.outputService = outputService;
|
this.outputService = outputService;
|
||||||
@@ -207,15 +207,7 @@ public class AgentRunService {
|
|||||||
if (run == null || !"RUNNING".equals(run.status())) {
|
if (run == null || !"RUNNING".equals(run.status())) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||||
}
|
}
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.interruptRunning(run.id());
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
|
||||||
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
|
|
||||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id AND status = 'RUNNING'
|
|
||||||
""")
|
|
||||||
.param("id", run.id())
|
|
||||||
.update();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
||||||
onCommit(() -> {
|
onCommit(() -> {
|
||||||
@@ -236,6 +228,19 @@ public class AgentRunService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public RunView resume(UUID projectId, Principal principal) {
|
public RunView resume(UUID projectId, Principal principal) {
|
||||||
|
return resume(projectId, null, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已中断 Run 的原阶段继续,并可显式选择本次恢复使用的模型。
|
||||||
|
*
|
||||||
|
* @param projectId 项目 ID
|
||||||
|
* @param modelConfigId 替代模型;为空时使用当前默认模型
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 新的恢复 Run
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) {
|
||||||
ProjectService.ProjectView project = projectService.require(projectId);
|
ProjectService.ProjectView project = projectService.require(projectId);
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
RunView interrupted = latest(projectId);
|
RunView interrupted = latest(projectId);
|
||||||
@@ -243,18 +248,71 @@ public class AgentRunService {
|
|||||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
|
||||||
}
|
}
|
||||||
String phase = runStore.interruptedPhase(interrupted, project);
|
String phase = runStore.interruptedPhase(interrupted, project);
|
||||||
RunView run = runStore.create(projectId, "RESUME", interrupted.id());
|
RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
|
||||||
projectService.updateStatus(projectId, phase);
|
projectService.updateStatus(projectId, phase);
|
||||||
|
scheduleResume(project, run, userId, phase);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将运行中的任务切换到替代模型,并以新的恢复 Run 保留完整审计边界。
|
||||||
|
*
|
||||||
|
* <p>旧 Run 在事务内先进入中断状态,新 Run 再绑定目标模型;任一步失败都会整体回滚。
|
||||||
|
* 目标 ID 可以与旧 Run 相同,以便模型配置被编辑后重新建立客户端并读取最新配置。
|
||||||
|
* 提交后先取消旧模型流,再从原业务阶段启动新 Run,复用相同 threadId 和工作区。</p>
|
||||||
|
*
|
||||||
|
* @param projectId 项目 ID
|
||||||
|
* @param modelConfigId 替代模型 ID
|
||||||
|
* @param principal 当前用户
|
||||||
|
* @return 绑定替代模型的新恢复 Run
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) {
|
||||||
|
ProjectService.ProjectView project = projectService.require(projectId);
|
||||||
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
|
RunView current = latest(projectId);
|
||||||
|
if (current == null || !"RUNNING".equals(current.status())) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||||
|
}
|
||||||
|
String phase = runStore.interruptedPhase(current, project);
|
||||||
|
requireTerminalUpdate(runMapper.interruptRunning(current.id()));
|
||||||
|
eventService.append(projectId, current.id(), "RUN_FINISHED", Map.of(
|
||||||
|
"outcome", "CANCELLED", "reason", "MODEL_SWITCH"));
|
||||||
|
|
||||||
|
RunView replacement = runStore.create(projectId, "RESUME", current.id(), modelConfigId);
|
||||||
|
eventService.append(projectId, replacement.id(), "MODEL_SWITCHED", Map.of(
|
||||||
|
"fromModelConfigId", current.modelConfigId(),
|
||||||
|
"toModelConfigId", replacement.modelConfigId()));
|
||||||
|
projectService.updateStatus(projectId, phase);
|
||||||
|
|
||||||
|
onCommit(() -> {
|
||||||
|
RunControl control = activeRuns.get(current.id());
|
||||||
|
if (control != null) {
|
||||||
|
control.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scheduleResume(project, replacement, userId, phase);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按中断前业务阶段注册恢复任务,所有调用方必须处于创建恢复 Run 的事务中。
|
||||||
|
*/
|
||||||
|
private void scheduleResume(
|
||||||
|
ProjectService.ProjectView project,
|
||||||
|
RunView run,
|
||||||
|
UUID userId,
|
||||||
|
String phase) {
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case "MATERIAL_CHECK" -> afterCommit(
|
case "MATERIAL_CHECK" -> afterCommit(
|
||||||
run.id(), () -> executeMaterialRun(project, run, true));
|
run.id(), () -> executeMaterialRun(project, run, true));
|
||||||
case "PLANNING" -> {
|
case "PLANNING" -> {
|
||||||
JsonNode materialResponse = runStore.latestMaterialResponse(projectId);
|
JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
|
||||||
afterCommit(run.id(), () -> executePlanningRun(
|
afterCommit(run.id(), () -> executePlanningRun(
|
||||||
project, run, userId, materialResponse, true));
|
project, run, userId, materialResponse, true));
|
||||||
}
|
}
|
||||||
case "WRITING" -> {
|
case "WRITING" -> {
|
||||||
ProjectService.PlanView plan = projectService.currentPlan(projectId);
|
ProjectService.PlanView plan = projectService.currentPlan(project.id());
|
||||||
if (plan == null || !"CONFIRMED".equals(plan.status())) {
|
if (plan == null || !"CONFIRMED".equals(plan.status())) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
|
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
|
||||||
}
|
}
|
||||||
@@ -263,7 +321,6 @@ public class AgentRunService {
|
|||||||
default -> throw new ApiException(
|
default -> throw new ApiException(
|
||||||
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
|
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
|
||||||
}
|
}
|
||||||
return run;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -473,15 +530,7 @@ public class AgentRunService {
|
|||||||
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
||||||
transactions.executeWithoutResult(status -> {
|
transactions.executeWithoutResult(status -> {
|
||||||
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.waitForInput(runId, interrupt.toString());
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb),
|
|
||||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id AND status = 'RUNNING'
|
|
||||||
""")
|
|
||||||
.param("interrupt", interrupt.toString())
|
|
||||||
.param("id", runId)
|
|
||||||
.update();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
||||||
});
|
});
|
||||||
@@ -575,14 +624,7 @@ public class AgentRunService {
|
|||||||
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
||||||
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
||||||
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.completeRunning(run.id());
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'COMPLETED', pending_interrupt = NULL,
|
|
||||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id AND status = 'RUNNING'
|
|
||||||
""")
|
|
||||||
.param("id", run.id())
|
|
||||||
.update();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
projectService.updateStatus(projectId, "DELIVERED");
|
projectService.updateStatus(projectId, "DELIVERED");
|
||||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
||||||
@@ -602,15 +644,7 @@ public class AgentRunService {
|
|||||||
: "Agent 执行失败,请稍后重试";
|
: "Agent 执行失败,请稍后重试";
|
||||||
try {
|
try {
|
||||||
transactions.executeWithoutResult(status -> {
|
transactions.executeWithoutResult(status -> {
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.failRunning(run.id(), message);
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED',
|
|
||||||
error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id AND status = 'RUNNING'
|
|
||||||
""")
|
|
||||||
.param("message", message)
|
|
||||||
.param("id", run.id())
|
|
||||||
.update();
|
|
||||||
if (updated == 1) {
|
if (updated == 1) {
|
||||||
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
||||||
"code", "AGENT_RUN_FAILED", "message", message));
|
"code", "AGENT_RUN_FAILED", "message", message));
|
||||||
@@ -639,6 +673,7 @@ public class AgentRunService {
|
|||||||
*
|
*
|
||||||
* @param id Run ID
|
* @param id Run ID
|
||||||
* @param projectId 项目 ID
|
* @param projectId 项目 ID
|
||||||
|
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
|
||||||
* @param triggerType 触发类型
|
* @param triggerType 触发类型
|
||||||
* @param status 运行状态
|
* @param status 运行状态
|
||||||
* @param pendingInterrupt 待处理 Ask JSON
|
* @param pendingInterrupt 待处理 Ask JSON
|
||||||
@@ -649,6 +684,7 @@ public class AgentRunService {
|
|||||||
public record RunView(
|
public record RunView(
|
||||||
UUID id,
|
UUID id,
|
||||||
UUID projectId,
|
UUID projectId,
|
||||||
|
UUID modelConfigId,
|
||||||
String triggerType,
|
String triggerType,
|
||||||
String status,
|
String status,
|
||||||
String pendingInterrupt,
|
String pendingInterrupt,
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.time.OffsetDateTime;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.manuagent.entity.AgentRunEntity;
|
||||||
|
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 集中读写 Agent Run 持久化状态。
|
* 集中读写 Agent Run 持久化状态。
|
||||||
@@ -17,17 +21,27 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class AgentRunStore {
|
public class AgentRunStore {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AgentRunMapper runMapper;
|
||||||
|
private final AgentEventMapper eventMapper;
|
||||||
|
private final ModelConfigMapper modelMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Run 状态存储。
|
* 创建 Run 状态存储。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param runMapper Agent Run Mapper
|
||||||
|
* @param eventMapper Agent 事件 Mapper
|
||||||
|
* @param modelMapper 模型配置 Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
|
public AgentRunStore(
|
||||||
this.jdbc = jdbc;
|
AgentRunMapper runMapper,
|
||||||
|
AgentEventMapper eventMapper,
|
||||||
|
ModelConfigMapper modelMapper,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.runMapper = runMapper;
|
||||||
|
this.eventMapper = eventMapper;
|
||||||
|
this.modelMapper = modelMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,34 +53,63 @@ public class AgentRunStore {
|
|||||||
* @param parentRunId 父 Run ID
|
* @param parentRunId 父 Run ID
|
||||||
* @return 新 Run
|
* @return 新 Run
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
||||||
Integer active = jdbc.sql("""
|
return create(projectId, triggerType, parentRunId, null);
|
||||||
SELECT count(*) FROM app.agent_run
|
}
|
||||||
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
|
|
||||||
""")
|
/**
|
||||||
.param("projectId", projectId)
|
* 创建绑定指定模型的新 Run;未指定模型时使用当前启用的默认模型。
|
||||||
.query(Integer.class)
|
*
|
||||||
.single();
|
* <p>模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换
|
||||||
|
* 只影响新 Run,不会悄悄改变已经开始的任务。</p>
|
||||||
|
*
|
||||||
|
* @param projectId 项目 ID
|
||||||
|
* @param triggerType 触发类型
|
||||||
|
* @param parentRunId 父 Run ID
|
||||||
|
* @param requestedModelId 用户显式选择的模型;为空时使用默认模型
|
||||||
|
* @return 新 Run
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // 查询只投影模型 ID,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||||
|
public AgentRunService.RunView create(
|
||||||
|
UUID projectId,
|
||||||
|
String triggerType,
|
||||||
|
UUID parentRunId,
|
||||||
|
UUID requestedModelId) {
|
||||||
|
QueryWrapper activeRuns = QueryWrapper.create()
|
||||||
|
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||||
|
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
|
||||||
|
long active = runMapper.selectCountByQuery(activeRuns);
|
||||||
if (active > 0) {
|
if (active > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
||||||
}
|
}
|
||||||
UUID id = UUID.randomUUID();
|
QueryWrapper modelQuery = QueryWrapper.create()
|
||||||
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
.select(ModelConfigEntity::getId);
|
||||||
.query(UUID.class)
|
if (requestedModelId == null) {
|
||||||
.single();
|
modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||||
jdbc.sql("""
|
} else {
|
||||||
INSERT INTO app.agent_run(
|
modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
|
||||||
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
|
}
|
||||||
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
|
modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
|
||||||
""")
|
ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
|
||||||
.param("id", id)
|
if (model == null) {
|
||||||
.param("projectId", projectId)
|
if (requestedModelId == null) {
|
||||||
.param("parentRunId", parentRunId)
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
|
||||||
.param("modelId", modelId)
|
}
|
||||||
.param("triggerType", triggerType)
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
|
||||||
.param("traceId", UUID.randomUUID().toString())
|
}
|
||||||
.update();
|
|
||||||
return require(id);
|
// 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。
|
||||||
|
AgentRunEntity entity = new AgentRunEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setProjectId(projectId);
|
||||||
|
entity.setParentRunId(parentRunId);
|
||||||
|
entity.setModelConfigId(model.getId());
|
||||||
|
entity.setTriggerType(triggerType);
|
||||||
|
entity.setStatus("RUNNING");
|
||||||
|
entity.setTraceId(UUID.randomUUID().toString());
|
||||||
|
runMapper.insertSelective(entity);
|
||||||
|
return require(entity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,11 +119,11 @@ public class AgentRunStore {
|
|||||||
* @return 最近 Run;不存在时为空
|
* @return 最近 Run;不存在时为空
|
||||||
*/
|
*/
|
||||||
public AgentRunService.RunView latest(UUID projectId) {
|
public AgentRunService.RunView latest(UUID projectId) {
|
||||||
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
|
QueryWrapper query = runViewQuery()
|
||||||
.param("projectId", projectId)
|
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||||
.query(AgentRunStore::mapRun)
|
.orderBy(AgentRunEntity::getCreatedAt).desc()
|
||||||
.optional()
|
.limit(1);
|
||||||
.orElse(null);
|
return toRunView(runMapper.selectOneByQuery(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,10 +133,14 @@ public class AgentRunStore {
|
|||||||
* @return Run
|
* @return Run
|
||||||
*/
|
*/
|
||||||
public AgentRunService.RunView require(UUID id) {
|
public AgentRunService.RunView require(UUID id) {
|
||||||
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
|
QueryWrapper query = runViewQuery()
|
||||||
.param("id", id)
|
.where(AgentRunEntity::getId).eq(id);
|
||||||
.query(AgentRunStore::mapRun)
|
AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
|
||||||
.single();
|
if (run == null) {
|
||||||
|
// 强制读取仅用于内部已知 ID;缺失表示持久化状态异常,而不是新增的 404 业务分支。
|
||||||
|
throw new IllegalStateException("Agent Run 不存在: " + id);
|
||||||
|
}
|
||||||
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,13 +149,7 @@ public class AgentRunStore {
|
|||||||
* @param runId Run ID
|
* @param runId Run ID
|
||||||
*/
|
*/
|
||||||
public void completeWaiting(UUID runId) {
|
public void completeWaiting(UUID runId) {
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.completeWaiting(runId);
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id AND status = 'WAITING_INPUT'
|
|
||||||
""")
|
|
||||||
.param("id", runId)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
||||||
}
|
}
|
||||||
@@ -142,10 +183,11 @@ public class AgentRunStore {
|
|||||||
* @param runId Run ID
|
* @param runId Run ID
|
||||||
*/
|
*/
|
||||||
public void ensureRunning(UUID runId) {
|
public void ensureRunning(UUID runId) {
|
||||||
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
|
String status = status(runId);
|
||||||
.param("id", runId)
|
if (status == null) {
|
||||||
.query(String.class)
|
// 保持迁移前强制单条查询对缺失记录的未预期异常语义。
|
||||||
.single();
|
throw new IllegalStateException("Agent Run 不存在: " + runId);
|
||||||
|
}
|
||||||
if (!"RUNNING".equals(status)) {
|
if (!"RUNNING".equals(status)) {
|
||||||
throw new AgentExecutionService.RunInterruptedException();
|
throw new AgentExecutionService.RunInterruptedException();
|
||||||
}
|
}
|
||||||
@@ -158,11 +200,7 @@ public class AgentRunStore {
|
|||||||
* @return 是否已停止
|
* @return 是否已停止
|
||||||
*/
|
*/
|
||||||
public boolean isInterrupted(UUID runId) {
|
public boolean isInterrupted(UUID runId) {
|
||||||
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
|
return "INTERRUPTED".equals(status(runId));
|
||||||
.param("id", runId)
|
|
||||||
.query(Boolean.class)
|
|
||||||
.optional()
|
|
||||||
.orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,15 +211,8 @@ public class AgentRunStore {
|
|||||||
* @return 业务阶段
|
* @return 业务阶段
|
||||||
*/
|
*/
|
||||||
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
||||||
return jdbc.sql("""
|
String phase = eventMapper.selectLatestStartedPhase(run.id());
|
||||||
SELECT payload ->> 'phase' FROM app.agent_event
|
return phase == null ? project.status() : phase;
|
||||||
WHERE run_id = :runId AND event_type = 'RUN_STARTED'
|
|
||||||
ORDER BY id DESC LIMIT 1
|
|
||||||
""")
|
|
||||||
.param("runId", run.id())
|
|
||||||
.query(String.class)
|
|
||||||
.optional()
|
|
||||||
.orElse(project.status());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,43 +222,75 @@ public class AgentRunStore {
|
|||||||
* @return 材料确认 JSON
|
* @return 材料确认 JSON
|
||||||
*/
|
*/
|
||||||
public JsonNode latestMaterialResponse(UUID projectId) {
|
public JsonNode latestMaterialResponse(UUID projectId) {
|
||||||
return jdbc.sql("""
|
String value = eventMapper.selectLatestMaterialResponseJson(projectId);
|
||||||
SELECT payload::text FROM app.agent_event
|
if (value == null) {
|
||||||
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
|
return objectMapper.createObjectNode();
|
||||||
AND jsonb_typeof(payload -> 'decisions') = 'array'
|
}
|
||||||
ORDER BY id DESC LIMIT 1
|
try {
|
||||||
""")
|
return objectMapper.readTree(value);
|
||||||
.param("projectId", projectId)
|
} catch (JsonProcessingException exception) {
|
||||||
.query(String.class)
|
throw new ApiException(
|
||||||
.optional()
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
.map(value -> {
|
"MATERIAL_RESPONSE_INVALID",
|
||||||
try {
|
"材料确认记录无法读取");
|
||||||
return objectMapper.readTree(value);
|
}
|
||||||
} catch (JsonProcessingException exception) {
|
|
||||||
throw new ApiException(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
"MATERIAL_RESPONSE_INVALID",
|
|
||||||
"材料确认记录无法读取");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.orElseGet(objectMapper::createObjectNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum)
|
/**
|
||||||
throws java.sql.SQLException {
|
* 将数据库实体转换为稳定的对外 Run 视图。
|
||||||
|
*
|
||||||
|
* @param entity Run 实体;不存在时为空
|
||||||
|
* @return Run 视图;不存在时为空
|
||||||
|
*/
|
||||||
|
private static AgentRunService.RunView toRunView(AgentRunEntity entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return new AgentRunService.RunView(
|
return new AgentRunService.RunView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getString("trigger_type"),
|
entity.getModelConfigId(),
|
||||||
rs.getString("status"),
|
entity.getTriggerType(),
|
||||||
rs.getString("pending_interrupt"),
|
entity.getStatus(),
|
||||||
rs.getString("error_message"),
|
entity.getPendingInterrupt(),
|
||||||
rs.getObject("started_at", OffsetDateTime.class),
|
entity.getErrorMessage(),
|
||||||
rs.getObject("ended_at", OffsetDateTime.class));
|
entity.getStartedAt(),
|
||||||
|
entity.getEndedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String RUN_SELECT = """
|
/**
|
||||||
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at
|
* 使用 BaseMapper 主键查询读取 Run 状态。
|
||||||
FROM app.agent_run
|
*
|
||||||
""";
|
* @param runId Run ID
|
||||||
|
* @return 当前状态;Run 不存在时为空
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // 这里只投影状态列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||||
|
private String status(UUID runId) {
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.select(AgentRunEntity::getStatus)
|
||||||
|
.where(AgentRunEntity::getId).eq(runId);
|
||||||
|
AgentRunEntity run = runMapper.selectOneByQuery(query);
|
||||||
|
return run == null ? null : run.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 Agent Run 对外视图所需的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。</p>
|
||||||
|
*
|
||||||
|
* @return Run 视图字段查询构造器
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private static QueryWrapper runViewQuery() {
|
||||||
|
return QueryWrapper.create().select(
|
||||||
|
AgentRunEntity::getId,
|
||||||
|
AgentRunEntity::getProjectId,
|
||||||
|
AgentRunEntity::getModelConfigId,
|
||||||
|
AgentRunEntity::getTriggerType,
|
||||||
|
AgentRunEntity::getStatus,
|
||||||
|
AgentRunEntity::getPendingInterrupt,
|
||||||
|
AgentRunEntity::getErrorMessage,
|
||||||
|
AgentRunEntity::getStartedAt,
|
||||||
|
AgentRunEntity::getEndedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package tech.easyflow.manuagent.agent;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
||||||
@@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Order(0)
|
@Order(0)
|
||||||
public class RunRecoveryService implements ApplicationRunner {
|
public class RunRecoveryService implements ApplicationRunner {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AgentRunMapper runMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建恢复服务。
|
* 创建恢复服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param runMapper Agent Run Mapper
|
||||||
*/
|
*/
|
||||||
public RunRecoveryService(JdbcClient jdbc) {
|
public RunRecoveryService(AgentRunMapper runMapper) {
|
||||||
this.jdbc = jdbc;
|
this.runMapper = runMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
jdbc.sql("""
|
runMapper.interruptRunningAfterRestart();
|
||||||
UPDATE app.agent_run
|
|
||||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
|
||||||
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
|
|
||||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE status = 'RUNNING'
|
|
||||||
""").update();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package tech.easyflow.manuagent.artifact;
|
package tech.easyflow.manuagent.artifact;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
|
import tech.easyflow.manuagent.entity.ArtifactEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -18,7 +21,6 @@ import java.util.UUID;
|
|||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,19 +29,20 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class ArtifactService {
|
public class ArtifactService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ArtifactMapper artifactMapper;
|
||||||
private final ProjectFileService fileService;
|
private final ProjectFileService fileService;
|
||||||
private final DocxValidator docxValidator;
|
private final DocxValidator docxValidator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建产物服务。
|
* 创建产物服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param artifactMapper 产物 Mapper
|
||||||
* @param fileService 项目文件服务
|
* @param fileService 项目文件服务
|
||||||
* @param docxValidator DOCX 校验器
|
* @param docxValidator DOCX 校验器
|
||||||
*/
|
*/
|
||||||
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
|
public ArtifactService(
|
||||||
this.jdbc = jdbc;
|
ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||||
|
this.artifactMapper = artifactMapper;
|
||||||
this.fileService = fileService;
|
this.fileService = fileService;
|
||||||
this.docxValidator = docxValidator;
|
this.docxValidator = docxValidator;
|
||||||
}
|
}
|
||||||
@@ -118,36 +121,20 @@ public class ArtifactService {
|
|||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
||||||
}
|
}
|
||||||
String hash = sha256(path);
|
String hash = sha256(path);
|
||||||
UUID id = jdbc.sql("""
|
// “项目 + 路径”必须原子 upsert,避免先查后写在并发重试时触发唯一约束竞态。
|
||||||
INSERT INTO app.artifact(
|
ArtifactEntity entity = new ArtifactEntity();
|
||||||
id, project_id, run_id, kind, name, relative_path, mime_type,
|
entity.setId(UUID.randomUUID());
|
||||||
size_bytes, sha256, metadata_json)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:id, :projectId, :runId, :kind, :name, :path,
|
entity.setRunId(runId);
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
entity.setKind(kind);
|
||||||
:size, :sha256, CAST(:metadata AS jsonb))
|
entity.setName(name);
|
||||||
ON CONFLICT (project_id, relative_path) DO UPDATE SET
|
entity.setRelativePath(relativePath);
|
||||||
run_id = EXCLUDED.run_id,
|
entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
kind = EXCLUDED.kind,
|
entity.setSizeBytes(size);
|
||||||
name = EXCLUDED.name,
|
entity.setSha256(hash);
|
||||||
mime_type = EXCLUDED.mime_type,
|
entity.setMetadataJson(metadata.toString());
|
||||||
size_bytes = EXCLUDED.size_bytes,
|
ArtifactEntity stored = artifactMapper.upsert(entity);
|
||||||
sha256 = EXCLUDED.sha256,
|
return toArtifactView(stored);
|
||||||
metadata_json = EXCLUDED.metadata_json,
|
|
||||||
published_at = CURRENT_TIMESTAMP
|
|
||||||
RETURNING id
|
|
||||||
""")
|
|
||||||
.param("id", UUID.randomUUID())
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.param("runId", runId)
|
|
||||||
.param("kind", kind)
|
|
||||||
.param("name", name)
|
|
||||||
.param("path", relativePath)
|
|
||||||
.param("size", size)
|
|
||||||
.param("sha256", hash)
|
|
||||||
.param("metadata", metadata.toString())
|
|
||||||
.query(UUID.class)
|
|
||||||
.single();
|
|
||||||
return require(id);
|
|
||||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
||||||
}
|
}
|
||||||
@@ -175,10 +162,12 @@ public class ArtifactService {
|
|||||||
* @return 按发布时间倒序的产物
|
* @return 按发布时间倒序的产物
|
||||||
*/
|
*/
|
||||||
public List<ArtifactView> list(UUID projectId) {
|
public List<ArtifactView> list(UUID projectId) {
|
||||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
|
QueryWrapper query = artifactViewQuery()
|
||||||
.param("projectId", projectId)
|
.where(ArtifactEntity::getProjectId).eq(projectId)
|
||||||
.query(ArtifactService::mapArtifact)
|
.orderBy(ArtifactEntity::getPublishedAt).desc();
|
||||||
.list();
|
return artifactMapper.selectListByQuery(query).stream()
|
||||||
|
.map(ArtifactService::toArtifactView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,21 +176,28 @@ public class ArtifactService {
|
|||||||
* @param artifactId 产物 ID
|
* @param artifactId 产物 ID
|
||||||
* @return 下载信息
|
* @return 下载信息
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public Download download(UUID artifactId) {
|
public Download download(UUID artifactId) {
|
||||||
StoredArtifact artifact = jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
|
.select(
|
||||||
FROM app.artifact WHERE id = :id
|
ArtifactEntity::getProjectId,
|
||||||
""")
|
ArtifactEntity::getName,
|
||||||
.param("id", artifactId)
|
ArtifactEntity::getRelativePath,
|
||||||
.query((rs, rowNum) -> new StoredArtifact(
|
ArtifactEntity::getMimeType,
|
||||||
rs.getObject("project_id", UUID.class),
|
ArtifactEntity::getSizeBytes,
|
||||||
rs.getString("name"),
|
ArtifactEntity::getSha256)
|
||||||
rs.getString("relative_path"),
|
.where(ArtifactEntity::getId).eq(artifactId);
|
||||||
rs.getString("mime_type"),
|
ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
|
||||||
rs.getLong("size_bytes"),
|
if (entity == null) {
|
||||||
rs.getString("sha256")))
|
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在");
|
||||||
.optional()
|
}
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
StoredArtifact artifact = new StoredArtifact(
|
||||||
|
entity.getProjectId(),
|
||||||
|
entity.getName(),
|
||||||
|
entity.getRelativePath(),
|
||||||
|
entity.getMimeType(),
|
||||||
|
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||||
|
entity.getSha256());
|
||||||
try {
|
try {
|
||||||
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
||||||
Resource resource = new UrlResource(path.toUri());
|
Resource resource = new UrlResource(path.toUri());
|
||||||
@@ -238,30 +234,44 @@ public class ArtifactService {
|
|||||||
return HexFormat.of().formatHex(digest.digest());
|
return HexFormat.of().formatHex(digest.digest());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArtifactView require(UUID id) {
|
/**
|
||||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id")
|
* 将产物实体转换成接口稳定视图。
|
||||||
.param("id", id)
|
*
|
||||||
.query(ArtifactService::mapArtifact)
|
* @param entity 产物实体
|
||||||
.optional()
|
* @return 产物接口视图
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
*/
|
||||||
}
|
private static ArtifactView toArtifactView(ArtifactEntity entity) {
|
||||||
|
|
||||||
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
|
||||||
return new ArtifactView(
|
return new ArtifactView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getObject("run_id", UUID.class),
|
entity.getRunId(),
|
||||||
rs.getString("kind"),
|
entity.getKind(),
|
||||||
rs.getString("name"),
|
entity.getName(),
|
||||||
rs.getLong("size_bytes"),
|
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||||
rs.getString("metadata_json"),
|
entity.getMetadataJson(),
|
||||||
rs.getObject("published_at", OffsetDateTime.class));
|
entity.getPublishedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String ARTIFACT_SELECT = """
|
/**
|
||||||
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
|
* 构造产物接口列表使用的最小字段投影。
|
||||||
FROM app.artifact
|
*
|
||||||
""";
|
* <p>该字段集合与迁移前 JDBC 列表 SQL 保持一致。下载路径、MIME 类型和 SHA-256
|
||||||
|
* 仅在下载场景读取,避免普通列表查询加载不参与响应的内部字段。</p>
|
||||||
|
*
|
||||||
|
* @return 只包含产物接口视图字段的查询构造器
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private static QueryWrapper artifactViewQuery() {
|
||||||
|
return QueryWrapper.create().select(
|
||||||
|
ArtifactEntity::getId,
|
||||||
|
ArtifactEntity::getProjectId,
|
||||||
|
ArtifactEntity::getRunId,
|
||||||
|
ArtifactEntity::getKind,
|
||||||
|
ArtifactEntity::getName,
|
||||||
|
ArtifactEntity::getSizeBytes,
|
||||||
|
ArtifactEntity::getMetadataJson,
|
||||||
|
ArtifactEntity::getPublishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产物元数据。
|
* 产物元数据。
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
package tech.easyflow.manuagent.auth;
|
package tech.easyflow.manuagent.auth;
|
||||||
|
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.security.core.userdetails.User;
|
import org.springframework.security.core.userdetails.User;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.core.annotation.Order;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
|
import tech.easyflow.manuagent.config.AppProperties;
|
||||||
|
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理单管理员账户和当前用户标识。
|
* 管理单管理员账户和当前用户标识。
|
||||||
@@ -22,19 +24,19 @@ import org.springframework.core.annotation.Order;
|
|||||||
@Order(1)
|
@Order(1)
|
||||||
public class UserService implements UserDetailsService, ApplicationRunner {
|
public class UserService implements UserDetailsService, ApplicationRunner {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AppUserMapper userMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AppProperties properties;
|
private final AppProperties properties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用户服务。
|
* 创建用户服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param userMapper 用户表 Mapper
|
||||||
* @param passwordEncoder 密码编码器
|
* @param passwordEncoder 密码编码器
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
|
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.userMapper = userMapper;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
}
|
}
|
||||||
@@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
|
long count = userMapper.selectCountByQuery(QueryWrapper.create());
|
||||||
if (count == 0) {
|
if (count > 0) {
|
||||||
jdbc.sql("""
|
return;
|
||||||
INSERT INTO app.app_user(id, username, password_hash, display_name)
|
|
||||||
VALUES (:id, :username, :password, :displayName)
|
|
||||||
""")
|
|
||||||
.param("id", UUID.randomUUID())
|
|
||||||
.param("username", properties.adminUsername())
|
|
||||||
.param("password", passwordEncoder.encode(properties.adminPassword()))
|
|
||||||
.param("displayName", "管理员")
|
|
||||||
.update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 首次启动时仍由应用层生成 UUID;selective insert 让 enabled 和时间字段沿用数据库默认值。
|
||||||
|
AppUserEntity administrator = new AppUserEntity();
|
||||||
|
administrator.setId(UUID.randomUUID());
|
||||||
|
administrator.setUsername(properties.adminUsername());
|
||||||
|
administrator.setPasswordHash(passwordEncoder.encode(properties.adminPassword()));
|
||||||
|
administrator.setDisplayName("管理员");
|
||||||
|
userMapper.insertSelective(administrator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,16 +70,23 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
|||||||
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
|
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("username", username)
|
.select(
|
||||||
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
|
AppUserEntity::getUsername,
|
||||||
.password(rs.getString("password_hash"))
|
AppUserEntity::getPasswordHash,
|
||||||
.roles("ADMIN")
|
AppUserEntity::getEnabled)
|
||||||
.disabled(!rs.getBoolean("enabled"))
|
.where(AppUserEntity::getUsername).eq(username);
|
||||||
.build())
|
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||||
.optional()
|
if (entity == null) {
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
|
throw new UsernameNotFoundException("账户不存在");
|
||||||
|
}
|
||||||
|
return User.withUsername(entity.getUsername())
|
||||||
|
.password(entity.getPasswordHash())
|
||||||
|
.roles("ADMIN")
|
||||||
|
.disabled(!Boolean.TRUE.equals(entity.getEnabled()))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,11 +96,15 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
|||||||
* @return 用户 UUID
|
* @return 用户 UUID
|
||||||
* @throws ApiException 用户不存在时抛出
|
* @throws ApiException 用户不存在时抛出
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public UUID requireUserId(String username) {
|
public UUID requireUserId(String username) {
|
||||||
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("username", username)
|
.select(AppUserEntity::getId)
|
||||||
.query(UUID.class)
|
.where(AppUserEntity::getUsername).eq(username);
|
||||||
.optional()
|
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
|
if (entity == null) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在");
|
||||||
|
}
|
||||||
|
return entity.getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
* 应用自身的运行配置。
|
* 应用自身的运行配置。
|
||||||
*
|
*
|
||||||
* @param dataRoot 项目材料、工作区和产物根目录
|
* @param dataRoot 项目材料、工作区和产物根目录
|
||||||
* @param deepseekKeyFile DeepSeek Key 文件
|
|
||||||
* @param dashscopeKeyFile 百炼 Key 文件
|
* @param dashscopeKeyFile 百炼 Key 文件
|
||||||
* @param masterKey 模型密钥加密主密钥
|
* @param masterKey 模型密钥加密主密钥
|
||||||
* @param adminUsername 本地管理员用户名
|
* @param adminUsername 本地管理员用户名
|
||||||
* @param adminPassword 本地管理员初始密码
|
* @param adminPassword 本地管理员初始密码
|
||||||
* @param modelBaseUrl 默认模型端点
|
|
||||||
* @param modelId 默认模型标识
|
|
||||||
* @param modelContextWindow 默认模型上下文窗口
|
|
||||||
* @param sandboxImage Agent Docker 运行镜像
|
* @param sandboxImage Agent Docker 运行镜像
|
||||||
* @param sandboxNetwork Agent Docker 网络
|
* @param sandboxNetwork Agent Docker 网络
|
||||||
* @param runTimeout 单次 Agent 运行超时
|
* @param runTimeout 单次 Agent 运行超时
|
||||||
@@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
@ConfigurationProperties(prefix = "app")
|
@ConfigurationProperties(prefix = "app")
|
||||||
public record AppProperties(
|
public record AppProperties(
|
||||||
Path dataRoot,
|
Path dataRoot,
|
||||||
Path deepseekKeyFile,
|
|
||||||
Path dashscopeKeyFile,
|
Path dashscopeKeyFile,
|
||||||
String masterKey,
|
String masterKey,
|
||||||
String adminUsername,
|
String adminUsername,
|
||||||
String adminPassword,
|
String adminPassword,
|
||||||
String modelBaseUrl,
|
|
||||||
String modelId,
|
|
||||||
int modelContextWindow,
|
|
||||||
String sandboxImage,
|
String sandboxImage,
|
||||||
String sandboxNetwork,
|
String sandboxNetwork,
|
||||||
Duration runTimeout) {
|
Duration runTimeout) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package tech.easyflow.manuagent.model;
|
package tech.easyflow.manuagent.model;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
@@ -72,7 +75,25 @@ public class ModelController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试模型连接。
|
* 使用管理页面当前草稿测试模型连接,但不保存草稿内容。
|
||||||
|
*
|
||||||
|
* <p>已有模型且 API 地址未变化时允许不提交 API Key,此时服务层只读取该模型已加密保存的密钥;
|
||||||
|
* 新模型或修改 API 地址后的草稿必须提交 API Key,避免把隐藏密钥转发到其他主机。</p>
|
||||||
|
*
|
||||||
|
* @param input 当前表单中的连接测试输入
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ModelService.ConnectionResult testDraft(
|
||||||
|
@Valid @RequestBody ModelService.ConnectionTestInput input) {
|
||||||
|
return modelService.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用数据库中已保存的完整配置测试模型连接。
|
||||||
|
*
|
||||||
|
* <p>保留该接口以兼容已有调用方;管理页面使用 {@code POST /api/models/test}
|
||||||
|
* 测试未保存草稿。</p>
|
||||||
*
|
*
|
||||||
* @param id 模型 ID
|
* @param id 模型 ID
|
||||||
* @return 测试结果
|
* @return 测试结果
|
||||||
@@ -93,4 +114,37 @@ public class ModelController {
|
|||||||
public void setDefault(@PathVariable UUID id, Principal principal) {
|
public void setDefault(@PathVariable UUID id, Principal principal) {
|
||||||
modelService.setDefault(id, principal);
|
modelService.setDefault(id, principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或停用模型。
|
||||||
|
*
|
||||||
|
* @param id 模型 ID
|
||||||
|
* @param input 状态输入
|
||||||
|
* @return 更新后的模型
|
||||||
|
*/
|
||||||
|
@PatchMapping("/{id}/enabled")
|
||||||
|
public ModelService.ModelView setEnabled(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody EnabledInput input) {
|
||||||
|
return modelService.setEnabled(id, input.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除从未被历史 Run 引用的非默认模型。
|
||||||
|
*
|
||||||
|
* @param id 模型 ID
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(@PathVariable UUID id) {
|
||||||
|
modelService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型启用状态输入。
|
||||||
|
*
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public record EnabledInput(@NotNull Boolean enabled) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
package tech.easyflow.manuagent.model;
|
package tech.easyflow.manuagent.model;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
|
||||||
|
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||||
|
import tech.easyflow.manuagent.entity.AgentRunEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.core.annotation.Order;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -30,89 +33,70 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
|
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Order(2)
|
public class ModelService {
|
||||||
public class ModelService implements ApplicationRunner {
|
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ModelConfigMapper modelMapper;
|
||||||
|
private final ModelAssignmentMapper assignmentMapper;
|
||||||
|
private final AgentRunMapper runMapper;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final KeyCipher keyCipher;
|
private final KeyCipher keyCipher;
|
||||||
private final AppProperties properties;
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建模型服务。
|
* 创建模型服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param modelMapper 模型配置 Mapper
|
||||||
|
* @param assignmentMapper 角色模型分配 Mapper
|
||||||
|
* @param runMapper Agent Run Mapper
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param keyCipher 密钥加密器
|
* @param keyCipher 密钥加密器
|
||||||
* @param properties 应用配置
|
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
|
@Autowired
|
||||||
public ModelService(
|
public ModelService(
|
||||||
JdbcClient jdbc,
|
ModelConfigMapper modelMapper,
|
||||||
|
ModelAssignmentMapper assignmentMapper,
|
||||||
|
AgentRunMapper runMapper,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
KeyCipher keyCipher,
|
KeyCipher keyCipher,
|
||||||
AppProperties properties,
|
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.jdbc = jdbc;
|
this(
|
||||||
this.userService = userService;
|
modelMapper,
|
||||||
this.keyCipher = keyCipher;
|
assignmentMapper,
|
||||||
this.properties = properties;
|
runMapper,
|
||||||
this.objectMapper = objectMapper;
|
userService,
|
||||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
keyCipher,
|
||||||
|
objectMapper,
|
||||||
|
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从项目根目录 Key 文件初始化默认模型。
|
* 创建可替换 HTTP 客户端的模型服务,仅供同包测试隔离外部网络边界。
|
||||||
*
|
*
|
||||||
* @param args 启动参数
|
* @param modelMapper 模型配置 Mapper
|
||||||
|
* @param assignmentMapper 角色模型分配 Mapper
|
||||||
|
* @param runMapper Agent Run Mapper
|
||||||
|
* @param userService 用户服务
|
||||||
|
* @param keyCipher 密钥加密器
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
* @param httpClient 模型连接使用的 HTTP 客户端
|
||||||
*/
|
*/
|
||||||
@Override
|
ModelService(
|
||||||
@Transactional
|
ModelConfigMapper modelMapper,
|
||||||
public void run(ApplicationArguments args) {
|
ModelAssignmentMapper assignmentMapper,
|
||||||
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
|
AgentRunMapper runMapper,
|
||||||
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
|
UserService userService,
|
||||||
return;
|
KeyCipher keyCipher,
|
||||||
}
|
ObjectMapper objectMapper,
|
||||||
try {
|
HttpClient httpClient) {
|
||||||
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
|
this.modelMapper = modelMapper;
|
||||||
if (key.isBlank()) {
|
this.assignmentMapper = assignmentMapper;
|
||||||
return;
|
this.runMapper = runMapper;
|
||||||
}
|
this.userService = userService;
|
||||||
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
|
this.keyCipher = keyCipher;
|
||||||
.query(UUID.class)
|
this.objectMapper = objectMapper;
|
||||||
.single();
|
this.httpClient = httpClient;
|
||||||
UUID modelId = UUID.randomUUID();
|
|
||||||
jdbc.sql("""
|
|
||||||
INSERT INTO app.model_config(
|
|
||||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
|
||||||
key_version, config_json, capabilities_json, is_default, created_by)
|
|
||||||
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
|
|
||||||
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
|
|
||||||
""")
|
|
||||||
.param("id", modelId)
|
|
||||||
.param("baseUrl", properties.modelBaseUrl())
|
|
||||||
.param("modelId", properties.modelId())
|
|
||||||
.param("ciphertext", keyCipher.encrypt(key))
|
|
||||||
.param("hint", hint(key))
|
|
||||||
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
|
|
||||||
.param("capabilities", json(Map.of(
|
|
||||||
"toolCalling", true,
|
|
||||||
"reasoning", true,
|
|
||||||
"contextWindow", properties.modelContextWindow())))
|
|
||||||
.param("userId", adminId)
|
|
||||||
.update();
|
|
||||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
|
||||||
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
|
|
||||||
.param("role", role)
|
|
||||||
.param("id", modelId)
|
|
||||||
.param("userId", adminId)
|
|
||||||
.update();
|
|
||||||
}
|
|
||||||
} catch (IOException exception) {
|
|
||||||
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,9 +105,12 @@ public class ModelService implements ApplicationRunner {
|
|||||||
* @return 模型列表
|
* @return 模型列表
|
||||||
*/
|
*/
|
||||||
public List<ModelView> list() {
|
public List<ModelView> list() {
|
||||||
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
|
QueryWrapper query = modelViewQuery()
|
||||||
.query(ModelService::mapModel)
|
.orderBy(ModelConfigEntity::getDefaultModel).desc()
|
||||||
.list();
|
.orderBy(ModelConfigEntity::getUpdatedAt).desc();
|
||||||
|
return modelMapper.selectListByQuery(query).stream()
|
||||||
|
.map(ModelService::toModelView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,57 +129,31 @@ public class ModelService implements ApplicationRunner {
|
|||||||
if (input.apiKey() == null || input.apiKey().isBlank()) {
|
if (input.apiKey() == null || input.apiKey().isBlank()) {
|
||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||||
}
|
}
|
||||||
|
QueryWrapper defaultQuery = QueryWrapper.create()
|
||||||
|
.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||||
|
boolean firstDefault = modelMapper.selectCountByQuery(defaultQuery) == 0;
|
||||||
id = UUID.randomUUID();
|
id = UUID.randomUUID();
|
||||||
jdbc.sql("""
|
ModelConfigEntity model = editableModel(id, input);
|
||||||
INSERT INTO app.model_config(
|
model.setProvider("OPENAI_COMPATIBLE");
|
||||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||||
key_version, config_json, capabilities_json, created_by)
|
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
|
model.setKeyVersion((short) 1);
|
||||||
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
|
model.setDefaultModel(firstDefault);
|
||||||
""")
|
model.setCreatedBy(userId);
|
||||||
.param("id", id)
|
modelMapper.insertModel(model);
|
||||||
.param("name", input.name().trim())
|
if (firstDefault) {
|
||||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||||
.param("modelId", input.modelId().trim())
|
upsertAssignment(role, id, userId);
|
||||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
}
|
||||||
.param("hint", hint(input.apiKey().trim()))
|
}
|
||||||
.param("config", json(input.config()))
|
|
||||||
.param("capabilities", json(input.capabilities()))
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
} else {
|
} else {
|
||||||
int updated = input.apiKey() == null || input.apiKey().isBlank()
|
ModelConfigEntity model = editableModel(id, input);
|
||||||
? jdbc.sql("""
|
if (input.apiKey() != null && !input.apiKey().isBlank()) {
|
||||||
UPDATE app.model_config
|
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
model.setKeyVersion((short) 1);
|
||||||
updated_at = CURRENT_TIMESTAMP
|
}
|
||||||
WHERE id = :id
|
int updated = modelMapper.updateModel(model);
|
||||||
""")
|
|
||||||
.param("name", input.name().trim())
|
|
||||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
|
||||||
.param("modelId", input.modelId().trim())
|
|
||||||
.param("config", json(input.config()))
|
|
||||||
.param("capabilities", json(input.capabilities()))
|
|
||||||
.param("id", id)
|
|
||||||
.update()
|
|
||||||
: jdbc.sql("""
|
|
||||||
UPDATE app.model_config
|
|
||||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
|
||||||
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
|
|
||||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id
|
|
||||||
""")
|
|
||||||
.param("name", input.name().trim())
|
|
||||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
|
||||||
.param("modelId", input.modelId().trim())
|
|
||||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
|
||||||
.param("hint", hint(input.apiKey().trim()))
|
|
||||||
.param("config", json(input.config()))
|
|
||||||
.param("capabilities", json(input.capabilities()))
|
|
||||||
.param("id", id)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
}
|
}
|
||||||
@@ -208,25 +169,64 @@ public class ModelService implements ApplicationRunner {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void setDefault(UUID id, Principal principal) {
|
public void setDefault(UUID id, Principal principal) {
|
||||||
require(id);
|
ModelView target = require(id);
|
||||||
|
if (!target.enabled()) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
|
||||||
|
}
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
modelMapper.clearDefault();
|
||||||
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
|
if (modelMapper.setDefault(id) != 1) {
|
||||||
.param("id", id)
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
.update();
|
}
|
||||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||||
jdbc.sql("""
|
upsertAssignment(role, id, userId);
|
||||||
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
|
}
|
||||||
VALUES (:role, :id, :userId)
|
}
|
||||||
ON CONFLICT (role) DO UPDATE
|
|
||||||
SET model_config_id = EXCLUDED.model_config_id,
|
/**
|
||||||
assigned_by = EXCLUDED.assigned_by,
|
* 更新模型启用状态。
|
||||||
updated_at = CURRENT_TIMESTAMP
|
*
|
||||||
""")
|
* <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
|
||||||
.param("role", role)
|
* 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
|
||||||
.param("id", id)
|
*
|
||||||
.param("userId", userId)
|
* @param id 模型 ID
|
||||||
.update();
|
* @param enabled 新启用状态
|
||||||
|
* @return 更新后的安全模型视图
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ModelView setEnabled(UUID id, boolean enabled) {
|
||||||
|
ModelView current = require(id);
|
||||||
|
if (!enabled && current.defaultModel()) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "请先设置新的默认模型");
|
||||||
|
}
|
||||||
|
if (!enabled && countRuns(id, true) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_IN_USE", "模型正在被运行中的任务使用");
|
||||||
|
}
|
||||||
|
if (modelMapper.setEnabled(id, enabled) != 1) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
|
}
|
||||||
|
return require(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真删除从未被历史 Run 使用的非默认模型。
|
||||||
|
*
|
||||||
|
* <p>历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。</p>
|
||||||
|
*
|
||||||
|
* @param id 模型 ID
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID id) {
|
||||||
|
ModelView current = require(id);
|
||||||
|
if (current.defaultModel()) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "默认模型不能删除");
|
||||||
|
}
|
||||||
|
if (countRuns(id, false) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_HISTORY_EXISTS", "模型已有任务记录,请改为停用");
|
||||||
|
}
|
||||||
|
assignmentMapper.deleteByModelConfigId(id);
|
||||||
|
if (modelMapper.deleteModel(id) != 1) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,40 @@ public class ModelService implements ApplicationRunner {
|
|||||||
* @return 测试结果
|
* @return 测试结果
|
||||||
*/
|
*/
|
||||||
public ConnectionResult test(UUID id) {
|
public ConnectionResult test(UUID id) {
|
||||||
ModelSecret model = requireSecret(id);
|
return testConnection(requireRuntimeModel(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。
|
||||||
|
*
|
||||||
|
* <p>编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key,
|
||||||
|
* 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。</p>
|
||||||
|
*
|
||||||
|
* @param input 当前模型连接草稿
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
public ConnectionResult test(ConnectionTestInput input) {
|
||||||
|
String baseUrl = normalizeBaseUrl(input.baseUrl());
|
||||||
|
String apiKey = input.apiKey() == null ? "" : input.apiKey().trim();
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
apiKey = storedApiKey(input.id(), baseUrl);
|
||||||
|
}
|
||||||
|
ModelSecret draft = new ModelSecret(
|
||||||
|
input.id(),
|
||||||
|
baseUrl,
|
||||||
|
input.modelId().trim(),
|
||||||
|
apiKey,
|
||||||
|
0);
|
||||||
|
return testConnection(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 OpenAI 兼容接口发送最小 Chat Completion 请求。
|
||||||
|
*
|
||||||
|
* @param model 已解析出明文密钥的临时连接配置
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
private ConnectionResult testConnection(ModelSecret model) {
|
||||||
String requestJson = json(Map.of(
|
String requestJson = json(Map.of(
|
||||||
"model", model.modelId(),
|
"model", model.modelId(),
|
||||||
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
|
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
|
||||||
@@ -272,36 +305,59 @@ public class ModelService implements ApplicationRunner {
|
|||||||
*
|
*
|
||||||
* @return 默认模型机密配置
|
* @return 默认模型机密配置
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public ModelSecret defaultModelSecret() {
|
public ModelSecret defaultModelSecret() {
|
||||||
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.query(UUID.class)
|
.select(
|
||||||
.optional()
|
ModelConfigEntity::getId,
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
|
ModelConfigEntity::getBaseUrl,
|
||||||
return requireSecret(id);
|
ModelConfigEntity::getModelId,
|
||||||
|
ModelConfigEntity::getApiKeyCiphertext,
|
||||||
|
ModelConfigEntity::getCapabilitiesJson)
|
||||||
|
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||||
|
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||||
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
|
if (model == null) {
|
||||||
|
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型");
|
||||||
|
}
|
||||||
|
return toModelSecret(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelView require(UUID id) {
|
private ModelView require(UUID id) {
|
||||||
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
|
QueryWrapper query = modelViewQuery()
|
||||||
.param("id", id)
|
.where(ModelConfigEntity::getId).eq(id);
|
||||||
.query(ModelService::mapModel)
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
.optional()
|
if (model == null) {
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
|
}
|
||||||
|
return toModelView(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelSecret requireSecret(UUID id) {
|
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||||
return jdbc.sql("""
|
/**
|
||||||
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
|
* 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key,仅供模型调用链使用。
|
||||||
FROM app.model_config WHERE id = :id AND enabled
|
*
|
||||||
""")
|
* <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run;
|
||||||
.param("id", id)
|
* 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
|
||||||
.query((rs, rowNum) -> new ModelSecret(
|
*
|
||||||
rs.getObject("id", UUID.class),
|
* @param id Run 绑定的模型配置 ID
|
||||||
rs.getString("base_url"),
|
* @return 可直接创建模型客户端的机密配置
|
||||||
rs.getString("model_id"),
|
*/
|
||||||
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
|
public ModelSecret requireRuntimeModel(UUID id) {
|
||||||
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.optional()
|
.select(
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
|
ModelConfigEntity::getId,
|
||||||
|
ModelConfigEntity::getBaseUrl,
|
||||||
|
ModelConfigEntity::getModelId,
|
||||||
|
ModelConfigEntity::getApiKeyCiphertext,
|
||||||
|
ModelConfigEntity::getCapabilitiesJson)
|
||||||
|
.where(ModelConfigEntity::getId).eq(id)
|
||||||
|
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||||
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
|
if (model == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用");
|
||||||
|
}
|
||||||
|
return toModelSecret(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,19 +395,134 @@ public class ModelService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/**
|
||||||
|
* 读取已有模型的保存密钥,供“API Key 留空”的草稿测试临时使用。
|
||||||
|
*
|
||||||
|
* <p>查询只投影主键和密文字段,不要求模型处于启用状态,因为管理员需要先验证配置,
|
||||||
|
* 再决定是否重新启用。密钥只在服务端内存中短暂解密,永不写入响应或日志。</p>
|
||||||
|
*
|
||||||
|
* @param id 已保存模型 ID;新增草稿没有 ID
|
||||||
|
* @param targetBaseUrl 已规范化的草稿 API 地址
|
||||||
|
* @return 已解密 API Key
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private String storedApiKey(UUID id, String targetBaseUrl) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "测试新模型需要 API Key");
|
||||||
|
}
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.select(
|
||||||
|
ModelConfigEntity::getId,
|
||||||
|
ModelConfigEntity::getBaseUrl,
|
||||||
|
ModelConfigEntity::getApiKeyCiphertext)
|
||||||
|
.where(ModelConfigEntity::getId).eq(id);
|
||||||
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
|
if (model == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
|
}
|
||||||
|
// 隐藏密钥只能发往它原本绑定的地址,草稿换址必须由用户显式提供新密钥。
|
||||||
|
if (!normalizeBaseUrl(model.getBaseUrl()).equals(targetBaseUrl)) {
|
||||||
|
throw new ApiException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"MODEL_KEY_REQUIRED_FOR_NEW_BASE_URL",
|
||||||
|
"API 地址变更后需要重新输入 API Key");
|
||||||
|
}
|
||||||
|
return keyCipher.decrypt(model.getApiKeyCiphertext());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造新增、更新共用的非敏感模型字段。
|
||||||
|
*
|
||||||
|
* @param id 模型主键
|
||||||
|
* @param input 接口输入
|
||||||
|
* @return 待持久化实体
|
||||||
|
*/
|
||||||
|
private ModelConfigEntity editableModel(UUID id, ModelInput input) {
|
||||||
|
ModelConfigEntity model = new ModelConfigEntity();
|
||||||
|
model.setId(id);
|
||||||
|
model.setName(input.name().trim());
|
||||||
|
model.setBaseUrl(normalizeBaseUrl(input.baseUrl()));
|
||||||
|
model.setModelId(input.modelId().trim());
|
||||||
|
model.setConfigJson(json(input.config()));
|
||||||
|
model.setCapabilitiesJson(json(input.capabilities()));
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子插入或更新一个 Agent 角色的模型分配。
|
||||||
|
*/
|
||||||
|
private void upsertAssignment(String role, UUID modelId, UUID userId) {
|
||||||
|
ModelAssignmentEntity assignment = new ModelAssignmentEntity();
|
||||||
|
assignment.setRole(role);
|
||||||
|
assignment.setModelConfigId(modelId);
|
||||||
|
assignment.setAssignedBy(userId);
|
||||||
|
assignmentMapper.upsert(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计模型的 Run 引用;停用检查只关心正在执行的 Run,删除检查覆盖全部历史记录。
|
||||||
|
*/
|
||||||
|
private long countRuns(UUID modelId, boolean runningOnly) {
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.where(AgentRunEntity::getModelConfigId).eq(modelId);
|
||||||
|
if (runningOnly) {
|
||||||
|
query.and(AgentRunEntity::getStatus).eq("RUNNING");
|
||||||
|
}
|
||||||
|
return runMapper.selectCountByQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将包含密文的内部实体转换为模型调用所需的最小明文对象。
|
||||||
|
*/
|
||||||
|
private ModelSecret toModelSecret(ModelConfigEntity model) {
|
||||||
|
return new ModelSecret(
|
||||||
|
model.getId(),
|
||||||
|
model.getBaseUrl(),
|
||||||
|
model.getModelId(),
|
||||||
|
keyCipher.decrypt(model.getApiKeyCiphertext()),
|
||||||
|
contextWindow(parseCapabilities(model.getCapabilitiesJson())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将模型实体转换为永不包含密文或明文 Key 的接口视图。
|
||||||
|
*/
|
||||||
|
private static ModelView toModelView(ModelConfigEntity model) {
|
||||||
return new ModelView(
|
return new ModelView(
|
||||||
rs.getObject("id", UUID.class),
|
model.getId(),
|
||||||
rs.getString("name"),
|
model.getName(),
|
||||||
rs.getString("provider"),
|
model.getProvider(),
|
||||||
rs.getString("base_url"),
|
model.getBaseUrl(),
|
||||||
rs.getString("model_id"),
|
model.getModelId(),
|
||||||
rs.getString("api_key_hint"),
|
model.getApiKeyHint(),
|
||||||
rs.getString("config_json"),
|
model.getConfigJson(),
|
||||||
rs.getString("capabilities_json"),
|
model.getCapabilitiesJson(),
|
||||||
rs.getBoolean("enabled"),
|
Boolean.TRUE.equals(model.getEnabled()),
|
||||||
rs.getBoolean("is_default"),
|
Boolean.TRUE.equals(model.getDefaultModel()),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
model.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造模型管理页面使用的安全字段投影。
|
||||||
|
*
|
||||||
|
* <p>普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 API Key 密文、
|
||||||
|
* 密钥版本和创建人等内部字段。只有模型连接和 Agent 创建路径可以读取密文。</p>
|
||||||
|
*
|
||||||
|
* @return 只包含模型接口展示字段的查询构造器
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private static QueryWrapper modelViewQuery() {
|
||||||
|
return QueryWrapper.create().select(
|
||||||
|
ModelConfigEntity::getId,
|
||||||
|
ModelConfigEntity::getName,
|
||||||
|
ModelConfigEntity::getProvider,
|
||||||
|
ModelConfigEntity::getBaseUrl,
|
||||||
|
ModelConfigEntity::getModelId,
|
||||||
|
ModelConfigEntity::getApiKeyHint,
|
||||||
|
ModelConfigEntity::getConfigJson,
|
||||||
|
ModelConfigEntity::getCapabilitiesJson,
|
||||||
|
ModelConfigEntity::getEnabled,
|
||||||
|
ModelConfigEntity::getDefaultModel,
|
||||||
|
ModelConfigEntity::getUpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String json(Object value) {
|
private String json(Object value) {
|
||||||
@@ -362,24 +533,56 @@ public class ModelService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并规范化用户提供的 OpenAI 兼容服务根地址。
|
||||||
|
*
|
||||||
|
* <p>模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。
|
||||||
|
* 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。
|
||||||
|
* 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。</p>
|
||||||
|
*
|
||||||
|
* @param baseUrl 用户输入的服务根地址
|
||||||
|
* @return 去除末尾斜杠后的规范地址
|
||||||
|
* @throws ApiException 地址结构或协议不符合要求时抛出
|
||||||
|
*/
|
||||||
private String normalizeBaseUrl(String baseUrl) {
|
private String normalizeBaseUrl(String baseUrl) {
|
||||||
String value = baseUrl.trim();
|
String value = baseUrl.trim();
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(value);
|
||||||
|
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||||
|
boolean httpScheme = "http".equals(scheme) || "https".equals(scheme);
|
||||||
|
boolean stableRequestTarget = uri.getHost() != null
|
||||||
|
&& !uri.getHost().isBlank()
|
||||||
|
&& uri.getUserInfo() == null
|
||||||
|
&& uri.getRawQuery() == null
|
||||||
|
&& uri.getRawFragment() == null;
|
||||||
|
if (!httpScheme || uri.isOpaque() || !stableRequestTarget) {
|
||||||
|
throw invalidBaseUrl();
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw invalidBaseUrl();
|
||||||
|
}
|
||||||
while (value.endsWith("/")) {
|
while (value.endsWith("/")) {
|
||||||
value = value.substring(0, value.length() - 1);
|
value = value.substring(0, value.length() - 1);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造不回显原始地址的统一校验异常,避免地址中意外携带的凭据进入日志或接口响应。
|
||||||
|
*
|
||||||
|
* @return 模型地址校验异常
|
||||||
|
*/
|
||||||
|
private ApiException invalidBaseUrl() {
|
||||||
|
return new ApiException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"MODEL_BASE_URL_INVALID",
|
||||||
|
"模型 API 地址必须是有效的 HTTP 或 HTTPS 地址,且不能包含用户信息、查询参数或片段");
|
||||||
|
}
|
||||||
|
|
||||||
private static String hint(String key) {
|
private static String hint(String key) {
|
||||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String MODEL_SELECT = """
|
|
||||||
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
|
|
||||||
capabilities_json, enabled, is_default, updated_at
|
|
||||||
FROM app.model_config
|
|
||||||
""";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模型编辑输入。
|
* 模型编辑输入。
|
||||||
*
|
*
|
||||||
@@ -391,14 +594,29 @@ public class ModelService implements ApplicationRunner {
|
|||||||
* @param capabilities 能力声明
|
* @param capabilities 能力声明
|
||||||
*/
|
*/
|
||||||
public record ModelInput(
|
public record ModelInput(
|
||||||
@NotBlank String name,
|
@NotBlank @Size(max = 100) String name,
|
||||||
@NotBlank String baseUrl,
|
@NotBlank @Size(max = 500) String baseUrl,
|
||||||
@NotBlank String modelId,
|
@NotBlank @Size(max = 255) String modelId,
|
||||||
String apiKey,
|
@Size(max = 4096) String apiKey,
|
||||||
Map<String, Object> config,
|
Map<String, Object> config,
|
||||||
Map<String, Object> capabilities) {
|
Map<String, Object> capabilities) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不落库的模型连接测试输入。
|
||||||
|
*
|
||||||
|
* @param id 已有模型 ID;新增草稿为 {@code null}
|
||||||
|
* @param baseUrl 当前表单中的 API 地址
|
||||||
|
* @param modelId 当前表单中的模型标识
|
||||||
|
* @param apiKey 当前表单中的新密钥;已有模型且 API 地址未变化时留空可复用保存密钥
|
||||||
|
*/
|
||||||
|
public record ConnectionTestInput(
|
||||||
|
UUID id,
|
||||||
|
@NotBlank @Size(max = 500) String baseUrl,
|
||||||
|
@NotBlank @Size(max = 255) String modelId,
|
||||||
|
@Size(max = 4096) String apiKey) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对外模型视图。
|
* 对外模型视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package tech.easyflow.manuagent.project;
|
package tech.easyflow.manuagent.project;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
import tech.easyflow.manuagent.config.AppProperties;
|
||||||
|
import tech.easyflow.manuagent.entity.ProjectFileEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -24,7 +27,6 @@ import org.apache.tika.Tika;
|
|||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -39,7 +41,7 @@ public class ProjectFileService {
|
|||||||
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
||||||
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ProjectFileMapper fileMapper;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ProjectService projectService;
|
private final ProjectService projectService;
|
||||||
private final Path dataRoot;
|
private final Path dataRoot;
|
||||||
@@ -48,17 +50,17 @@ public class ProjectFileService {
|
|||||||
/**
|
/**
|
||||||
* 创建材料服务。
|
* 创建材料服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param fileMapper 项目材料 Mapper
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param projectService 项目服务
|
* @param projectService 项目服务
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public ProjectFileService(
|
public ProjectFileService(
|
||||||
JdbcClient jdbc,
|
ProjectFileMapper fileMapper,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
ProjectService projectService,
|
ProjectService projectService,
|
||||||
AppProperties properties) {
|
AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.fileMapper = fileMapper;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.projectService = projectService;
|
this.projectService = projectService;
|
||||||
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
||||||
@@ -142,24 +144,19 @@ public class ProjectFileService {
|
|||||||
Files.move(temporary, target);
|
Files.move(temporary, target);
|
||||||
moved = true;
|
moved = true;
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("""
|
// 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。
|
||||||
INSERT INTO app.project_file(
|
ProjectFileEntity entity = new ProjectFileEntity();
|
||||||
id, project_id, original_name, stored_name, relative_path, mime_type,
|
entity.setId(fileId);
|
||||||
extension, size_bytes, sha256, uploaded_by)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
|
entity.setOriginalName(originalName);
|
||||||
:extension, :sizeBytes, :sha256, :userId)
|
entity.setStoredName(target.getFileName().toString());
|
||||||
""")
|
entity.setRelativePath(workspacePath);
|
||||||
.param("id", fileId)
|
entity.setMimeType(mime);
|
||||||
.param("projectId", projectId)
|
entity.setExtension(extension);
|
||||||
.param("originalName", originalName)
|
entity.setSizeBytes(Files.size(target));
|
||||||
.param("storedName", target.getFileName().toString())
|
entity.setSha256(HexFormat.of().formatHex(digest.digest()));
|
||||||
.param("relativePath", workspacePath)
|
entity.setUploadedBy(userId);
|
||||||
.param("mimeType", mime)
|
fileMapper.insertSelective(entity);
|
||||||
.param("extension", extension)
|
|
||||||
.param("sizeBytes", Files.size(target))
|
|
||||||
.param("sha256", HexFormat.of().formatHex(digest.digest()))
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
return require(fileId);
|
return require(fileId);
|
||||||
} catch (FileAlreadyExistsException exception) {
|
} catch (FileAlreadyExistsException exception) {
|
||||||
cleanupFailedUpload(exception, temporary);
|
cleanupFailedUpload(exception, temporary);
|
||||||
@@ -197,16 +194,13 @@ public class ProjectFileService {
|
|||||||
*/
|
*/
|
||||||
public List<FileView> list(UUID projectId) {
|
public List<FileView> list(UUID projectId) {
|
||||||
projectService.require(projectId);
|
projectService.require(projectId);
|
||||||
return jdbc.sql("""
|
QueryWrapper query = fileViewQuery()
|
||||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
.where(ProjectFileEntity::getProjectId).eq(projectId)
|
||||||
size_bytes, status, created_at
|
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||||
FROM app.project_file
|
.orderBy(ProjectFileEntity::getRelativePath).asc();
|
||||||
WHERE project_id = :projectId AND deleted_at IS NULL
|
return fileMapper.selectListByQuery(query).stream()
|
||||||
ORDER BY relative_path
|
.map(ProjectFileService::toFileView)
|
||||||
""")
|
.toList();
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(ProjectFileService::mapFile)
|
|
||||||
.list();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,18 +210,22 @@ public class ProjectFileService {
|
|||||||
* @param fileId 文件 ID
|
* @param fileId 文件 ID
|
||||||
* @return 文件资源
|
* @return 文件资源
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public Download download(UUID projectId, UUID fileId) {
|
public Download download(UUID projectId, UUID fileId) {
|
||||||
StoredFile stored = jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT original_name, relative_path, mime_type
|
.select(
|
||||||
FROM app.project_file
|
ProjectFileEntity::getOriginalName,
|
||||||
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
|
ProjectFileEntity::getRelativePath,
|
||||||
""")
|
ProjectFileEntity::getMimeType)
|
||||||
.param("fileId", fileId)
|
.where(ProjectFileEntity::getId).eq(fileId)
|
||||||
.param("projectId", projectId)
|
.and(ProjectFileEntity::getProjectId).eq(projectId)
|
||||||
.query((rs, rowNum) -> new StoredFile(
|
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||||
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
|
.and(ProjectFileEntity::getStatus).eq("READY");
|
||||||
.optional()
|
ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
if (entity == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||||
|
}
|
||||||
|
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
|
||||||
try {
|
try {
|
||||||
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
@@ -314,28 +312,54 @@ public class ProjectFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private FileView require(UUID fileId) {
|
private FileView require(UUID fileId) {
|
||||||
return jdbc.sql("""
|
ProjectFileEntity entity = fileMapper.selectOneByQuery(
|
||||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
|
||||||
size_bytes, status, created_at
|
if (entity == null) {
|
||||||
FROM app.project_file WHERE id = :id
|
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||||
""")
|
}
|
||||||
.param("id", fileId)
|
return toFileView(entity);
|
||||||
.query(ProjectFileService::mapFile)
|
|
||||||
.optional()
|
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/**
|
||||||
|
* 构造项目材料接口视图使用的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>该投影与迁移前 JDBC 列表和单条查询的显式字段保持一致,仅排除存储文件名、
|
||||||
|
* 文件摘要、上传人和更新时间等当前接口不需要的内部列。查询条件和业务判断仍由
|
||||||
|
* 调用方追加,因此本方法只承担 ORM 查询字段收敛,不改变任何业务语义。</p>
|
||||||
|
*
|
||||||
|
* @return 只包含文件接口视图字段的查询构造器
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private static QueryWrapper fileViewQuery() {
|
||||||
|
return QueryWrapper.create().select(
|
||||||
|
ProjectFileEntity::getId,
|
||||||
|
ProjectFileEntity::getProjectId,
|
||||||
|
ProjectFileEntity::getOriginalName,
|
||||||
|
ProjectFileEntity::getRelativePath,
|
||||||
|
ProjectFileEntity::getMimeType,
|
||||||
|
ProjectFileEntity::getExtension,
|
||||||
|
ProjectFileEntity::getSizeBytes,
|
||||||
|
ProjectFileEntity::getStatus,
|
||||||
|
ProjectFileEntity::getCreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将持久化实体转换为稳定的接口视图,避免把数据库字段直接暴露给控制器。
|
||||||
|
*
|
||||||
|
* @param entity 项目材料实体
|
||||||
|
* @return 文件接口视图
|
||||||
|
*/
|
||||||
|
private static FileView toFileView(ProjectFileEntity entity) {
|
||||||
return new FileView(
|
return new FileView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getString("original_name"),
|
entity.getOriginalName(),
|
||||||
rs.getString("relative_path"),
|
entity.getRelativePath(),
|
||||||
rs.getString("mime_type"),
|
entity.getMimeType(),
|
||||||
rs.getString("extension"),
|
entity.getExtension(),
|
||||||
rs.getLong("size_bytes"),
|
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safeName(String originalName) {
|
private String safeName(String originalName) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package tech.easyflow.manuagent.project;
|
package tech.easyflow.manuagent.project;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
|
import tech.easyflow.manuagent.entity.ProjectEntity;
|
||||||
|
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -10,7 +15,6 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class ProjectService {
|
public class ProjectService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ProjectMapper projectMapper;
|
||||||
|
private final ProjectPlanMapper planMapper;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建项目服务。
|
* 创建项目服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param projectMapper 项目 Mapper
|
||||||
|
* @param planMapper 规划版本 Mapper
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
|
public ProjectService(
|
||||||
this.jdbc = jdbc;
|
ProjectMapper projectMapper,
|
||||||
|
ProjectPlanMapper planMapper,
|
||||||
|
UserService userService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.projectMapper = projectMapper;
|
||||||
|
this.planMapper = planMapper;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@@ -51,18 +62,14 @@ public class ProjectService {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
String threadId = "project-" + id;
|
String threadId = "project-" + id;
|
||||||
jdbc.sql("""
|
ProjectEntity entity = new ProjectEntity();
|
||||||
INSERT INTO app.project(
|
entity.setId(id);
|
||||||
id, company_name, project_name, agui_thread_id, application_level, created_by)
|
entity.setCompanyName(companyName.trim());
|
||||||
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
|
entity.setProjectName(companyName.trim());
|
||||||
""")
|
entity.setAguiThreadId(threadId);
|
||||||
.param("id", id)
|
entity.setApplicationLevel(level);
|
||||||
.param("companyName", companyName.trim())
|
entity.setCreatedBy(userId);
|
||||||
.param("projectName", companyName.trim())
|
projectMapper.insertSelective(entity);
|
||||||
.param("threadId", threadId)
|
|
||||||
.param("level", level)
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
return require(id);
|
return require(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +79,10 @@ public class ProjectService {
|
|||||||
* @return 按更新时间倒序的项目
|
* @return 按更新时间倒序的项目
|
||||||
*/
|
*/
|
||||||
public List<ProjectView> list() {
|
public List<ProjectView> list() {
|
||||||
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
|
QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
|
||||||
.query(ProjectService::mapProject)
|
return projectMapper.selectListByQuery(query).stream()
|
||||||
.list();
|
.map(ProjectService::toProjectView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,11 +93,12 @@ public class ProjectService {
|
|||||||
* @throws ApiException 项目不存在时抛出
|
* @throws ApiException 项目不存在时抛出
|
||||||
*/
|
*/
|
||||||
public ProjectView require(UUID projectId) {
|
public ProjectView require(UUID projectId) {
|
||||||
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
|
ProjectEntity entity = projectMapper.selectOneByQuery(
|
||||||
.param("id", projectId)
|
projectViewQuery().where(ProjectEntity::getId).eq(projectId));
|
||||||
.query(ProjectService::mapProject)
|
if (entity == null) {
|
||||||
.optional()
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
|
}
|
||||||
|
return toProjectView(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,26 +110,17 @@ public class ProjectService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(UUID projectId) {
|
public void delete(UUID projectId) {
|
||||||
require(projectId);
|
require(projectId);
|
||||||
boolean running = jdbc.sql("""
|
if (projectMapper.hasRunningRun(projectId)) {
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(Boolean.class)
|
|
||||||
.single();
|
|
||||||
if (running) {
|
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
// 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
|
||||||
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
|
projectMapper.deleteEvents(projectId);
|
||||||
.param("projectId", projectId)
|
projectMapper.deleteArtifacts(projectId);
|
||||||
.update();
|
projectMapper.deletePlans(projectId);
|
||||||
}
|
projectMapper.deleteFiles(projectId);
|
||||||
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
|
projectMapper.deleteRuns(projectId);
|
||||||
.param("projectId", projectId)
|
int deleted = projectMapper.deleteById(projectId);
|
||||||
.update();
|
|
||||||
if (deleted != 1) {
|
if (deleted != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
}
|
}
|
||||||
@@ -133,14 +133,7 @@ public class ProjectService {
|
|||||||
* @param status 新阶段
|
* @param status 新阶段
|
||||||
*/
|
*/
|
||||||
public void updateStatus(UUID projectId, String status) {
|
public void updateStatus(UUID projectId, String status) {
|
||||||
int updated = jdbc.sql("""
|
int updated = projectMapper.updateStatus(projectId, status);
|
||||||
UPDATE app.project
|
|
||||||
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id
|
|
||||||
""")
|
|
||||||
.param("status", status)
|
|
||||||
.param("id", projectId)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
}
|
}
|
||||||
@@ -156,23 +149,14 @@ public class ProjectService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
|
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
|
||||||
Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id")
|
ProjectPlanEntity draft = new ProjectPlanEntity();
|
||||||
.param("id", projectId)
|
draft.setId(UUID.randomUUID());
|
||||||
.query(Integer.class)
|
draft.setProjectId(projectId);
|
||||||
.single();
|
draft.setPlanJson(plan.toString());
|
||||||
UUID planId = UUID.randomUUID();
|
draft.setCreatedBy(userId);
|
||||||
jdbc.sql("""
|
ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
|
||||||
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
|
||||||
VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId)
|
|
||||||
""")
|
|
||||||
.param("id", planId)
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.param("version", version)
|
|
||||||
.param("plan", plan.toString())
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
updateStatus(projectId, "PLANNING");
|
updateStatus(projectId, "PLANNING");
|
||||||
return requirePlan(planId);
|
return toPlanView(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,17 +166,8 @@ public class ProjectService {
|
|||||||
* @return 最新规划;不存在时返回空
|
* @return 最新规划;不存在时返回空
|
||||||
*/
|
*/
|
||||||
public PlanView currentPlan(UUID projectId) {
|
public PlanView currentPlan(UUID projectId) {
|
||||||
return jdbc.sql("""
|
ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
|
||||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
return entity == null ? null : toPlanView(entity);
|
||||||
FROM app.project_plan
|
|
||||||
WHERE project_id = :projectId
|
|
||||||
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(this::mapPlan)
|
|
||||||
.optional()
|
|
||||||
.orElse(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,61 +182,65 @@ public class ProjectService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
|
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
int updated = jdbc.sql("""
|
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
|
||||||
UPDATE app.project_plan
|
if (confirmed == null) {
|
||||||
SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId,
|
|
||||||
confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT'
|
|
||||||
""")
|
|
||||||
.param("plan", plan.toString())
|
|
||||||
.param("userId", userId)
|
|
||||||
.param("planId", planId)
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
||||||
}
|
}
|
||||||
updateStatus(projectId, "WRITING");
|
updateStatus(projectId, "WRITING");
|
||||||
return requirePlan(planId);
|
return toPlanView(confirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PlanView requirePlan(UUID planId) {
|
/** 将规划实体解析成包含 JsonNode 的接口视图。 */
|
||||||
return jdbc.sql("""
|
private PlanView toPlanView(ProjectPlanEntity entity) {
|
||||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
|
||||||
FROM app.project_plan WHERE id = :id
|
|
||||||
""")
|
|
||||||
.param("id", planId)
|
|
||||||
.query(this::mapPlan)
|
|
||||||
.optional()
|
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
|
||||||
try {
|
try {
|
||||||
return new PlanView(
|
return new PlanView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getInt("plan_version"),
|
entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
objectMapper.readTree(rs.getString("plan_json")),
|
objectMapper.readTree(entity.getPlanJson()),
|
||||||
rs.getObject("confirmed_at", OffsetDateTime.class),
|
entity.getConfirmedAt(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
} catch (JsonProcessingException exception) {
|
} catch (JsonProcessingException exception) {
|
||||||
throw new java.sql.SQLException("规划 JSON 无法解析", exception);
|
// 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。
|
||||||
|
throw new IllegalStateException("规划 JSON 无法解析", exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/** 将项目实体转换为接口视图。 */
|
||||||
|
private static ProjectView toProjectView(ProjectEntity entity) {
|
||||||
return new ProjectView(
|
return new ProjectView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getString("company_name"),
|
entity.getCompanyName(),
|
||||||
rs.getString("project_name"),
|
entity.getProjectName(),
|
||||||
rs.getString("agui_thread_id"),
|
entity.getAguiThreadId(),
|
||||||
rs.getString("application_level"),
|
entity.getApplicationLevel(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
rs.getLong("version"),
|
entity.getVersion() == null ? 0L : entity.getVersion(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class),
|
entity.getCreatedAt(),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
entity.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造项目接口列表与详情共用的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>字段集合与迁移前 JDBC 查询保持一致,创建人只参与写入和审计,不属于当前项目接口响应,
|
||||||
|
* 因而不在普通列表和单条读取时加载。调用方继续负责追加排序或主键条件。</p>
|
||||||
|
*
|
||||||
|
* @return 只包含项目接口视图字段的查询构造器
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
|
private static QueryWrapper projectViewQuery() {
|
||||||
|
return QueryWrapper.create().select(
|
||||||
|
ProjectEntity::getId,
|
||||||
|
ProjectEntity::getCompanyName,
|
||||||
|
ProjectEntity::getProjectName,
|
||||||
|
ProjectEntity::getAguiThreadId,
|
||||||
|
ProjectEntity::getApplicationLevel,
|
||||||
|
ProjectEntity::getStatus,
|
||||||
|
ProjectEntity::getVersion,
|
||||||
|
ProjectEntity::getCreatedAt,
|
||||||
|
ProjectEntity::getUpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeLevel(String level) {
|
private String normalizeLevel(String level) {
|
||||||
@@ -272,12 +251,6 @@ public class ProjectService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String PROJECT_SELECT = """
|
|
||||||
SELECT id, company_name, project_name, agui_thread_id, application_level, status,
|
|
||||||
version, created_at, updated_at
|
|
||||||
FROM app.project
|
|
||||||
""";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目视图。
|
* 项目视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package tech.easyflow.manuagent.skill;
|
package tech.easyflow.manuagent.skill;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
import tech.easyflow.manuagent.config.AppProperties;
|
||||||
|
import tech.easyflow.manuagent.entity.SkillConfigEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.SkillViewRow;
|
||||||
import io.agentscope.core.skill.AgentSkill;
|
import io.agentscope.core.skill.AgentSkill;
|
||||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -18,7 +22,6 @@ import java.util.UUID;
|
|||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -32,7 +35,7 @@ public class SkillService {
|
|||||||
private static final int MAX_ZIP_ENTRIES = 500;
|
private static final int MAX_ZIP_ENTRIES = 500;
|
||||||
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final SkillConfigMapper skillMapper;
|
||||||
private final PostgresSkillRepository repository;
|
private final PostgresSkillRepository repository;
|
||||||
private final SkillPackageReader packageReader;
|
private final SkillPackageReader packageReader;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
@@ -41,19 +44,19 @@ public class SkillService {
|
|||||||
/**
|
/**
|
||||||
* 创建 Skill 服务。
|
* 创建 Skill 服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param skillMapper 应用 Skill 配置 Mapper
|
||||||
* @param repository AgentScope PostgreSQL 仓库
|
* @param repository AgentScope PostgreSQL 仓库
|
||||||
* @param packageReader Skill 包读取器
|
* @param packageReader Skill 包读取器
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public SkillService(
|
public SkillService(
|
||||||
JdbcClient jdbc,
|
SkillConfigMapper skillMapper,
|
||||||
PostgresSkillRepository repository,
|
PostgresSkillRepository repository,
|
||||||
SkillPackageReader packageReader,
|
SkillPackageReader packageReader,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
AppProperties properties) {
|
AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.skillMapper = skillMapper;
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.packageReader = packageReader;
|
this.packageReader = packageReader;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -66,9 +69,9 @@ public class SkillService {
|
|||||||
* @return Skill 列表
|
* @return Skill 列表
|
||||||
*/
|
*/
|
||||||
public List<SkillView> list() {
|
public List<SkillView> list() {
|
||||||
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
|
return skillMapper.selectViews().stream()
|
||||||
.query(SkillService::mapSkill)
|
.map(SkillService::toSkillView)
|
||||||
.list();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,11 +81,11 @@ public class SkillService {
|
|||||||
* @return Skill 详情
|
* @return Skill 详情
|
||||||
*/
|
*/
|
||||||
public SkillDetail require(String name) {
|
public SkillDetail require(String name) {
|
||||||
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
|
SkillViewRow row = skillMapper.selectView(name);
|
||||||
.param("name", name)
|
if (row == null) {
|
||||||
.query(SkillService::mapSkill)
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||||
.optional()
|
}
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
SkillView view = toSkillView(row);
|
||||||
AgentSkill skill = repository.getSkill(name);
|
AgentSkill skill = repository.getSkill(name);
|
||||||
if (skill == null) {
|
if (skill == null) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
||||||
@@ -116,13 +119,7 @@ public class SkillService {
|
|||||||
* @param enabled 是否启用
|
* @param enabled 是否启用
|
||||||
*/
|
*/
|
||||||
public void setEnabled(String name, boolean enabled) {
|
public void setEnabled(String name, boolean enabled) {
|
||||||
int updated = jdbc.sql("""
|
int updated = skillMapper.updateEnabled(name, enabled);
|
||||||
UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE skill_name = :name AND validation_status = 'VALID'
|
|
||||||
""")
|
|
||||||
.param("enabled", enabled)
|
|
||||||
.param("name", name)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
||||||
}
|
}
|
||||||
@@ -134,14 +131,15 @@ public class SkillService {
|
|||||||
*
|
*
|
||||||
* @return Skill 名称数组
|
* @return Skill 名称数组
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public String[] enabledNames() {
|
public String[] enabledNames() {
|
||||||
return jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT skill_name FROM app.skill_config
|
.select(SkillConfigEntity::getSkillName)
|
||||||
WHERE enabled AND validation_status = 'VALID'
|
.where(SkillConfigEntity::getEnabled).eq(true)
|
||||||
ORDER BY skill_name
|
.and(SkillConfigEntity::getValidationStatus).eq("VALID")
|
||||||
""")
|
.orderBy(SkillConfigEntity::getSkillName).asc();
|
||||||
.query(String.class)
|
return skillMapper.selectListByQuery(query).stream()
|
||||||
.list()
|
.map(SkillConfigEntity::getSkillName)
|
||||||
.toArray(String[]::new);
|
.toArray(String[]::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,17 +169,17 @@ public class SkillService {
|
|||||||
}
|
}
|
||||||
repository.save(List.of(skillPackage.skill()), false);
|
repository.save(List.of(skillPackage.skill()), false);
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("""
|
SkillConfigEntity config = new SkillConfigEntity();
|
||||||
INSERT INTO app.skill_config(
|
config.setSkillName(name);
|
||||||
skill_name, version, source_type, enabled, read_only, checksum,
|
config.setVersion(skillPackage.version());
|
||||||
validation_status, imported_by)
|
config.setSourceType("IMPORTED");
|
||||||
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
|
config.setEnabled(false);
|
||||||
""")
|
config.setReadOnly(true);
|
||||||
.param("name", name)
|
config.setChecksum(skillPackage.checksum());
|
||||||
.param("version", skillPackage.version())
|
config.setValidationStatus("VALID");
|
||||||
.param("checksum", skillPackage.checksum())
|
config.setImportedBy(userId);
|
||||||
.param("userId", userId)
|
// Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
|
||||||
.update();
|
skillMapper.insertSelectiveWithPk(config);
|
||||||
return require(name).view();
|
return require(name).view();
|
||||||
} finally {
|
} finally {
|
||||||
deleteTree(temporary);
|
deleteTree(temporary);
|
||||||
@@ -197,13 +195,16 @@ public class SkillService {
|
|||||||
* @param name Skill 名称
|
* @param name Skill 名称
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public void deleteImported(String name) {
|
public void deleteImported(String name) {
|
||||||
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("name", name)
|
.select(SkillConfigEntity::getSourceType)
|
||||||
.query(String.class)
|
.where(SkillConfigEntity::getSkillName).eq(name);
|
||||||
.optional()
|
SkillConfigEntity config = skillMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
if (config == null) {
|
||||||
if (!"IMPORTED".equals(sourceType)) {
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||||
|
}
|
||||||
|
if (!"IMPORTED".equals(config.getSourceType())) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
|
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
|
||||||
}
|
}
|
||||||
repository.delete(name);
|
repository.delete(name);
|
||||||
@@ -311,26 +312,22 @@ public class SkillService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/**
|
||||||
|
* 将跨 schema 的只读查询行转换成对外 Skill 视图。
|
||||||
|
*/
|
||||||
|
private static SkillView toSkillView(SkillViewRow row) {
|
||||||
return new SkillView(
|
return new SkillView(
|
||||||
rs.getString("name"),
|
row.getName(),
|
||||||
rs.getString("description"),
|
row.getDescription(),
|
||||||
rs.getString("version"),
|
row.getVersion(),
|
||||||
rs.getString("source_type"),
|
row.getSourceType(),
|
||||||
rs.getBoolean("enabled"),
|
Boolean.TRUE.equals(row.getEnabled()),
|
||||||
rs.getBoolean("read_only"),
|
Boolean.TRUE.equals(row.getReadOnly()),
|
||||||
rs.getString("validation_status"),
|
row.getValidationStatus(),
|
||||||
rs.getString("validation_message"),
|
row.getValidationMessage(),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
row.getUpdatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String SKILL_SELECT = """
|
|
||||||
SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
|
|
||||||
c.validation_status, c.validation_message, c.updated_at
|
|
||||||
FROM agentscope.agentscope_skills s
|
|
||||||
JOIN app.skill_config c ON c.skill_name = s.name
|
|
||||||
""";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skill 列表视图。
|
* Skill 列表视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ spring:
|
|||||||
jackson:
|
jackson:
|
||||||
default-property-inclusion: non_null
|
default-property-inclusion: non_null
|
||||||
|
|
||||||
|
mybatis-flex:
|
||||||
|
mapper-locations:
|
||||||
|
- classpath*:/mapper/**/*.xml
|
||||||
|
type-aliases-package: tech.easyflow.manuagent.entity
|
||||||
|
type-handlers-package: tech.easyflow.manuagent.typehandler
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
cache-enabled: false
|
||||||
|
local-cache-scope: statement
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
servlet:
|
servlet:
|
||||||
@@ -32,14 +42,10 @@ server:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
data-root: file:../data
|
data-root: file:../data
|
||||||
deepseek-key-file: ./deepseek_key.txt
|
|
||||||
dashscope-key-file: ./dashscope_key.txt
|
dashscope-key-file: ./dashscope_key.txt
|
||||||
master-key: smart-factory-local-master-key
|
master-key: smart-factory-local-master-key
|
||||||
admin-username: admin
|
admin-username: admin
|
||||||
admin-password: admin123
|
admin-password: admin123
|
||||||
model-base-url: https://api.deepseek.com
|
|
||||||
model-id: deepseek-v4-flash
|
|
||||||
model-context-window: 131072
|
|
||||||
sandbox-image: smart-factory-agent-runtime:0.1.0
|
sandbox-image: smart-factory-agent-runtime:0.1.0
|
||||||
sandbox-network: bridge
|
sandbox-network: bridge
|
||||||
run-timeout: 60m
|
run-timeout: 60m
|
||||||
|
|||||||
57
server/src/main/resources/mapper/AgentEventMapper.xml
Normal file
57
server/src/main/resources/mapper/AgentEventMapper.xml
Normal 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 ->> '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 -> 'decisions') = 'array'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
58
server/src/main/resources/mapper/AgentRunMapper.xml
Normal file
58
server/src/main/resources/mapper/AgentRunMapper.xml
Normal 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>
|
||||||
37
server/src/main/resources/mapper/ArtifactMapper.xml
Normal file
37
server/src/main/resources/mapper/ArtifactMapper.xml
Normal 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>
|
||||||
24
server/src/main/resources/mapper/ModelAssignmentMapper.xml
Normal file
24
server/src/main/resources/mapper/ModelAssignmentMapper.xml
Normal 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>
|
||||||
75
server/src/main/resources/mapper/ModelConfigMapper.xml
Normal file
75
server/src/main/resources/mapper/ModelConfigMapper.xml
Normal 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>
|
||||||
42
server/src/main/resources/mapper/ProjectMapper.xml
Normal file
42
server/src/main/resources/mapper/ProjectMapper.xml
Normal 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>
|
||||||
50
server/src/main/resources/mapper/ProjectPlanMapper.xml
Normal file
50
server/src/main/resources/mapper/ProjectPlanMapper.xml
Normal 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>
|
||||||
35
server/src/main/resources/mapper/SkillConfigMapper.xml
Normal file
35
server/src/main/resources/mapper/SkillConfigMapper.xml
Normal 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>
|
||||||
@@ -1,24 +1,58 @@
|
|||||||
package tech.easyflow.manuagent;
|
package tech.easyflow.manuagent;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import tech.easyflow.manuagent.agent.AgentEventService;
|
import tech.easyflow.manuagent.agent.AgentEventService;
|
||||||
|
import tech.easyflow.manuagent.agent.AgentRunService;
|
||||||
|
import tech.easyflow.manuagent.agent.AgentRunStore;
|
||||||
import tech.easyflow.manuagent.artifact.ArtifactService;
|
import tech.easyflow.manuagent.artifact.ArtifactService;
|
||||||
import tech.easyflow.manuagent.artifact.DocxValidator;
|
import tech.easyflow.manuagent.artifact.DocxValidator;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
|
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||||
|
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||||
|
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||||||
|
import tech.easyflow.manuagent.model.KeyCipher;
|
||||||
|
import tech.easyflow.manuagent.model.ModelService;
|
||||||
|
import tech.easyflow.manuagent.config.AppProperties;
|
||||||
|
import tech.easyflow.manuagent.skill.SkillPackageReader;
|
||||||
|
import tech.easyflow.manuagent.skill.SkillService;
|
||||||
|
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||||
|
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
|
||||||
|
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
|
||||||
import tech.easyflow.manuagent.project.ProjectService;
|
import tech.easyflow.manuagent.project.ProjectService;
|
||||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.mybatisflex.core.MybatisFlexBootstrap;
|
||||||
|
import com.mybatisflex.core.datasource.FlexDataSource;
|
||||||
|
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.core.table.TableInfo;
|
||||||
|
import com.mybatisflex.core.table.TableInfoFactory;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.time.Duration;
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||||
|
import org.apache.ibatis.io.Resources;
|
||||||
|
import org.apache.ibatis.mapping.Environment;
|
||||||
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -77,7 +111,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
* 验证事件按项目全局 ID 增量回放且不重复。
|
* 验证事件按项目全局 ID 增量回放且不重复。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldReplayEventsAfterCursorInOrder() {
|
void shouldReplayEventsAfterCursorInOrder() throws Exception {
|
||||||
JdbcClient jdbc = jdbc();
|
JdbcClient jdbc = jdbc();
|
||||||
UUID userId = UUID.randomUUID();
|
UUID userId = UUID.randomUUID();
|
||||||
UUID modelId = UUID.randomUUID();
|
UUID modelId = UUID.randomUUID();
|
||||||
@@ -87,7 +121,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
.param("id", userId).param("name", "u-" + userId).update();
|
.param("id", userId).param("name", "u-" + userId).update();
|
||||||
jdbc.sql("""
|
jdbc.sql("""
|
||||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
|
||||||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||||||
jdbc.sql("""
|
jdbc.sql("""
|
||||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||||
@@ -99,7 +133,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||||||
.param("trace", UUID.randomUUID().toString()).update();
|
.param("trace", UUID.randomUUID().toString()).update();
|
||||||
|
|
||||||
AgentEventService service = new AgentEventService(jdbc, new ObjectMapper());
|
AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper());
|
||||||
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
|
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
|
||||||
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
|
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
|
||||||
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
||||||
@@ -152,7 +186,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
Files.writeString(document, "first");
|
Files.writeString(document, "first");
|
||||||
ProjectFileService files = mock(ProjectFileService.class);
|
ProjectFileService files = mock(ProjectFileService.class);
|
||||||
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
||||||
ArtifactService artifacts = new ArtifactService(jdbc, files, new DocxValidator());
|
ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator());
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
ArtifactService.ArtifactView first = artifacts.publish(
|
ArtifactService.ArtifactView first = artifacts.publish(
|
||||||
@@ -173,7 +207,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
* 验证项目真删除会清除所有关联业务记录。
|
* 验证项目真删除会清除所有关联业务记录。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldDeleteProjectRecords() {
|
void shouldDeleteProjectRecords() throws Exception {
|
||||||
JdbcClient jdbc = jdbc();
|
JdbcClient jdbc = jdbc();
|
||||||
UUID userId = UUID.randomUUID();
|
UUID userId = UUID.randomUUID();
|
||||||
UUID projectId = UUID.randomUUID();
|
UUID projectId = UUID.randomUUID();
|
||||||
@@ -213,7 +247,8 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
||||||
.param("sha", "0".repeat(64)).update();
|
.param("sha", "0".repeat(64)).update();
|
||||||
|
|
||||||
ProjectService service = new ProjectService(jdbc, mock(UserService.class), new ObjectMapper());
|
ProjectService service = new ProjectService(
|
||||||
|
projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper());
|
||||||
service.delete(projectId);
|
service.delete(projectId);
|
||||||
|
|
||||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||||||
@@ -229,11 +264,489 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
.single()).isZero();
|
.single()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
|
||||||
|
*
|
||||||
|
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
|
||||||
|
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
|
||||||
|
JdbcClient jdbc = jdbc();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID modelId = UUID.randomUUID();
|
||||||
|
UUID projectId = UUID.randomUUID();
|
||||||
|
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||||
|
.param("id", userId).param("name", "run-u-" + userId).update();
|
||||||
|
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
|
||||||
|
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||||
|
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||||||
|
""").param("id", modelId).param("name", "run-m-" + modelId).update();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||||
|
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
|
||||||
|
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
|
||||||
|
.param("userId", userId).update();
|
||||||
|
|
||||||
|
AgentRunMapper mapper = agentRunMapper();
|
||||||
|
AgentEventMapper events = agentEventMapper();
|
||||||
|
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), new ObjectMapper());
|
||||||
|
AgentRunService.RunView initial = store.create(projectId, "INITIAL", null);
|
||||||
|
assertThat(initial.status()).isEqualTo("RUNNING");
|
||||||
|
assertThat(store.latest(projectId).id()).isEqualTo(initial.id());
|
||||||
|
store.ensureRunning(initial.id());
|
||||||
|
|
||||||
|
String interrupt = "{\"kind\":\"material_check\",\"items\":[]}";
|
||||||
|
assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1);
|
||||||
|
assertThat(new ObjectMapper().readTree(
|
||||||
|
store.requireWaiting(projectId, "material_check").pendingInterrupt()))
|
||||||
|
.isEqualTo(new ObjectMapper().readTree(interrupt));
|
||||||
|
store.completeWaiting(initial.id());
|
||||||
|
assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED");
|
||||||
|
|
||||||
|
AgentRunService.RunView resumed = store.create(projectId, "RESUME", initial.id());
|
||||||
|
AgentEventEntity started = new AgentEventEntity();
|
||||||
|
started.setProjectId(projectId);
|
||||||
|
started.setRunId(resumed.id());
|
||||||
|
started.setEventType("RUN_STARTED");
|
||||||
|
started.setEventId(UUID.randomUUID().toString());
|
||||||
|
started.setPayloadJson("{\"phase\":\"PLANNING\"}");
|
||||||
|
events.insertReturning(started);
|
||||||
|
AgentEventEntity response = new AgentEventEntity();
|
||||||
|
response.setProjectId(projectId);
|
||||||
|
response.setRunId(resumed.id());
|
||||||
|
response.setEventType("ASK_RESPONDED");
|
||||||
|
response.setEventId(UUID.randomUUID().toString());
|
||||||
|
response.setPayloadJson("{\"decisions\":[]}");
|
||||||
|
events.insertReturning(response);
|
||||||
|
assertThat(events.selectLatestStartedPhase(resumed.id())).isEqualTo("PLANNING");
|
||||||
|
assertThat(new ObjectMapper().readTree(events.selectLatestMaterialResponseJson(projectId)))
|
||||||
|
.isEqualTo(new ObjectMapper().readTree("{\"decisions\":[]}"));
|
||||||
|
|
||||||
|
assertThat(mapper.interruptRunning(resumed.id())).isEqualTo(1);
|
||||||
|
assertThat(mapper.interruptRunning(resumed.id())).isZero();
|
||||||
|
assertThat(store.isInterrupted(resumed.id())).isTrue();
|
||||||
|
|
||||||
|
AgentRunService.RunView restartCandidate = store.create(projectId, "RETRY", resumed.id());
|
||||||
|
assertThat(mapper.interruptRunningAfterRestart()).isGreaterThanOrEqualTo(1);
|
||||||
|
assertThat(mapper.selectOneById(restartCandidate.id()).getErrorCode()).isEqualTo("PROCESS_RESTARTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证规划草稿使用递增版本写入 JSONB,并且只有 DRAFT 可以原子确认。
|
||||||
|
*
|
||||||
|
* @throws Exception Mapper XML 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldSaveAndConfirmProjectPlanWithMyBatisFlex() throws Exception {
|
||||||
|
JdbcClient jdbc = jdbc();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID projectId = UUID.randomUUID();
|
||||||
|
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||||
|
.param("id", userId).param("name", "plan-u-" + userId).update();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||||
|
VALUES (:id, '规划企业', '规划项目', :thread, 'ADVANCED', :userId)
|
||||||
|
""").param("id", projectId).param("thread", "plan-t-" + projectId).param("userId", userId).update();
|
||||||
|
|
||||||
|
UserService users = mock(UserService.class);
|
||||||
|
when(users.requireUserId("admin")).thenReturn(userId);
|
||||||
|
ProjectService service = new ProjectService(
|
||||||
|
projectMapper(), projectPlanMapper(), users, new ObjectMapper());
|
||||||
|
ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
ProjectService.PlanView draft = service.saveDraftPlan(
|
||||||
|
projectId, json.createObjectNode().put("title", "第一版"), userId);
|
||||||
|
ProjectService.PlanView confirmed = service.confirmPlan(
|
||||||
|
projectId,
|
||||||
|
draft.id(),
|
||||||
|
json.createObjectNode().put("title", "确认版"),
|
||||||
|
() -> "admin");
|
||||||
|
|
||||||
|
assertThat(draft.version()).isEqualTo(1);
|
||||||
|
assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id());
|
||||||
|
assertThat(confirmed.status()).isEqualTo("CONFIRMED");
|
||||||
|
assertThat(confirmed.plan().path("title").asText()).isEqualTo("确认版");
|
||||||
|
assertThat(service.require(projectId).status()).isEqualTo("WRITING");
|
||||||
|
assertThat(service.require(projectId).version()).isEqualTo(2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换。
|
||||||
|
*
|
||||||
|
* @throws Exception Mapper XML 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldManageEncryptedModelConfigurationWithMyBatisFlex() throws Exception {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
jdbc().sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||||
|
.param("id", userId).param("name", "model-u-" + userId).update();
|
||||||
|
UserService users = mock(UserService.class);
|
||||||
|
when(users.requireUserId("admin")).thenReturn(userId);
|
||||||
|
AppProperties properties = new AppProperties(
|
||||||
|
temporaryDirectory,
|
||||||
|
temporaryDirectory.resolve("dashscope.key"),
|
||||||
|
"integration-master-key",
|
||||||
|
"admin",
|
||||||
|
"admin",
|
||||||
|
"runtime:test",
|
||||||
|
"bridge",
|
||||||
|
Duration.ofMinutes(1));
|
||||||
|
ModelService service = new ModelService(
|
||||||
|
modelConfigMapper(),
|
||||||
|
modelAssignmentMapper(),
|
||||||
|
agentRunMapper(),
|
||||||
|
users,
|
||||||
|
new KeyCipher(properties),
|
||||||
|
new ObjectMapper());
|
||||||
|
Map<String, Object> capabilities = Map.of(
|
||||||
|
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
|
||||||
|
|
||||||
|
ModelService.ModelView created = service.save(
|
||||||
|
null,
|
||||||
|
new ModelService.ModelInput(
|
||||||
|
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
|
||||||
|
Map.of("timeoutSeconds", 60), capabilities),
|
||||||
|
() -> "admin");
|
||||||
|
ModelService.ModelView updated = service.save(
|
||||||
|
created.id(),
|
||||||
|
new ModelService.ModelInput(
|
||||||
|
"测试模型更新", "https://model.example.test", "model-v2", "",
|
||||||
|
Map.of("timeoutSeconds", 120), capabilities),
|
||||||
|
() -> "admin");
|
||||||
|
ModelService.ModelView second = service.save(
|
||||||
|
null,
|
||||||
|
new ModelService.ModelInput(
|
||||||
|
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
|
||||||
|
Map.of("timeoutSeconds", 90), capabilities),
|
||||||
|
() -> "admin");
|
||||||
|
|
||||||
|
assertThat(created.defaultModel()).isTrue();
|
||||||
|
assertThat(second.defaultModel()).isFalse();
|
||||||
|
|
||||||
|
service.setDefault(second.id(), () -> "admin");
|
||||||
|
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
|
||||||
|
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
|
||||||
|
|
||||||
|
assertThat(updated.name()).isEqualTo("测试模型更新");
|
||||||
|
assertThat(updated.apiKeyHint()).endsWith("1234");
|
||||||
|
assertThat(firstSecret.apiKey()).isEqualTo("secret-1234");
|
||||||
|
assertThat(firstSecret.modelId()).isEqualTo("model-v2");
|
||||||
|
assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678");
|
||||||
|
assertThat(defaultSecret.modelId()).isEqualTo("model-second");
|
||||||
|
assertThat(defaultSecret.contextWindow()).isEqualTo(65_536);
|
||||||
|
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
|
||||||
|
.param("id", second.id()).query(Long.class).single()).isEqualTo(3L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.setEnabled(second.id(), false))
|
||||||
|
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
|
||||||
|
assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED"));
|
||||||
|
ModelService.ModelView disabled = service.setEnabled(created.id(), false);
|
||||||
|
assertThat(disabled.enabled()).isFalse();
|
||||||
|
|
||||||
|
// 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。
|
||||||
|
UUID historyProjectId = UUID.randomUUID();
|
||||||
|
UUID historyRunId = UUID.randomUUID();
|
||||||
|
jdbc().sql("""
|
||||||
|
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||||
|
VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId)
|
||||||
|
""").param("id", historyProjectId).param("threadId", "thread-" + historyProjectId)
|
||||||
|
.param("userId", userId).update();
|
||||||
|
jdbc().sql("""
|
||||||
|
INSERT INTO app.agent_run(
|
||||||
|
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
|
||||||
|
VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP)
|
||||||
|
""").param("id", historyRunId).param("projectId", historyProjectId)
|
||||||
|
.param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.delete(created.id()))
|
||||||
|
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
|
||||||
|
assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS"));
|
||||||
|
|
||||||
|
// 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。
|
||||||
|
jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update();
|
||||||
|
jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update();
|
||||||
|
service.delete(created.id());
|
||||||
|
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id")
|
||||||
|
.param("id", created.id()).query(Long.class).single()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。
|
||||||
|
*
|
||||||
|
* @throws Exception Mapper XML 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception {
|
||||||
|
SkillConfigMapper mapper = skillConfigMapper();
|
||||||
|
AppProperties properties = new AppProperties(
|
||||||
|
temporaryDirectory,
|
||||||
|
temporaryDirectory.resolve("dashscope.key"),
|
||||||
|
"integration-master-key",
|
||||||
|
"admin",
|
||||||
|
"admin",
|
||||||
|
"runtime:test",
|
||||||
|
"bridge",
|
||||||
|
Duration.ofMinutes(1));
|
||||||
|
SkillService service = new SkillService(
|
||||||
|
mapper,
|
||||||
|
mock(PostgresSkillRepository.class),
|
||||||
|
mock(SkillPackageReader.class),
|
||||||
|
mock(UserService.class),
|
||||||
|
properties);
|
||||||
|
|
||||||
|
List<SkillService.SkillView> skills = service.list();
|
||||||
|
assertThat(skills).isNotEmpty();
|
||||||
|
String name = skills.getFirst().name();
|
||||||
|
service.setEnabled(name, false);
|
||||||
|
|
||||||
|
assertThat(service.enabledNames()).doesNotContain(name);
|
||||||
|
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
|
||||||
|
.param("name", name).query(Boolean.class).single()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldPersistAndQueryUserWithMyBatisFlex() {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
AppUserMapper mapper = new MybatisFlexBootstrap()
|
||||||
|
.setDataSource(source)
|
||||||
|
.addMapper(AppUserMapper.class)
|
||||||
|
.start()
|
||||||
|
.getMapper(AppUserMapper.class);
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUserEntity user = new AppUserEntity();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setUsername("flex-" + userId);
|
||||||
|
user.setPasswordHash("encoded");
|
||||||
|
user.setDisplayName("Flex 测试用户");
|
||||||
|
|
||||||
|
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
|
||||||
|
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
|
||||||
|
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
|
||||||
|
|
||||||
|
// 主键由应用层提前生成;Generator 策略必须保留已有值,其他空字段交给数据库默认值。
|
||||||
|
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
|
||||||
|
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.where(AppUserEntity::getUsername).eq(user.getUsername());
|
||||||
|
AppUserEntity loaded = mapper.selectOneByQuery(query);
|
||||||
|
assertThat(loaded.getId()).isEqualTo(userId);
|
||||||
|
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
|
||||||
|
assertThat(loaded.getEnabled()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
private JdbcClient jdbc() {
|
private JdbcClient jdbc() {
|
||||||
|
return JdbcClient.create(dataSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PGSimpleDataSource dataSource() {
|
||||||
PGSimpleDataSource source = new PGSimpleDataSource();
|
PGSimpleDataSource source = new PGSimpleDataSource();
|
||||||
source.setURL(POSTGRES.getJdbcUrl());
|
source.setURL(POSTGRES.getJdbcUrl());
|
||||||
source.setUser(POSTGRES.getUsername());
|
source.setUser(POSTGRES.getUsername());
|
||||||
source.setPassword(POSTGRES.getPassword());
|
source.setPassword(POSTGRES.getPassword());
|
||||||
return JdbcClient.create(source);
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
|
||||||
|
*
|
||||||
|
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap,
|
||||||
|
* 因而需要显式复现相同配置。</p>
|
||||||
|
*
|
||||||
|
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private ArtifactMapper artifactMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ArtifactMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/ArtifactMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(ArtifactMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建加载事件原子写入和游标回放 SQL 的 Agent 事件 Mapper。
|
||||||
|
*
|
||||||
|
* @return Agent 事件 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private AgentEventMapper agentEventMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("agent-event-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"agent-event-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(AgentEventMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/AgentEventMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(AgentEventMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建加载 Run 状态机 SQL 及 UUID、JSONB 类型处理器的 Agent Run Mapper。
|
||||||
|
*
|
||||||
|
* @return Agent Run Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private AgentRunMapper agentRunMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("agent-run-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"agent-run-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(AgentRunMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/AgentRunMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(AgentRunMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建加载了项目级联删除 XML 的项目 Mapper。
|
||||||
|
*
|
||||||
|
* @return 项目 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private ProjectMapper projectMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("project-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"project-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ProjectMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/ProjectMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(ProjectMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建加载了规划版本 SQL 与 JSONB 处理器的规划 Mapper。
|
||||||
|
*
|
||||||
|
* @return 规划 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private ProjectPlanMapper projectPlanMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("plan-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"plan-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ProjectPlanMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/ProjectPlanMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(ProjectPlanMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用 MyBatis-Flex Wrapper,并加载 JSONB 显式写入 SQL 的模型配置 Mapper。
|
||||||
|
*
|
||||||
|
* @return 模型配置 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private ModelConfigMapper modelConfigMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("model-config-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"model-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ModelConfigMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/ModelConfigMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(ModelConfigMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建加载角色 upsert SQL 的模型分配 Mapper。 */
|
||||||
|
private ModelAssignmentMapper modelAssignmentMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("model-assignment-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"model-assignment-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ModelAssignmentMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/ModelAssignmentMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(ModelAssignmentMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建加载 AgentScope 只读联查 SQL 的 Skill 配置 Mapper。 */
|
||||||
|
private SkillConfigMapper skillConfigMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("skill-config-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"skill-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(SkillConfigMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/SkillConfigMapper.xml";
|
||||||
|
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||||
|
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
return bootstrap.getMapper(SkillConfigMapper.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class KeyCipherAndShellTest {
|
|||||||
|
|
||||||
private AppProperties properties() {
|
private AppProperties properties() {
|
||||||
return new AppProperties(
|
return new AppProperties(
|
||||||
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"),
|
Path.of("data"), Path.of("dashscope"),
|
||||||
"unit-test-master", "admin", "admin123", "https://api.example.test", "model",
|
"unit-test-master", "admin", "admin123",
|
||||||
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
|
"smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,62 @@
|
|||||||
package tech.easyflow.manuagent.agent;
|
package tech.easyflow.manuagent.agent;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
|
import tech.easyflow.manuagent.project.ProjectService;
|
||||||
|
import tech.easyflow.manuagent.skill.SkillService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Agent 事件持久化的精简规则。
|
* 验证 Agent 事件持久化的精简规则。
|
||||||
*/
|
*/
|
||||||
class AgentExecutionServiceTest {
|
class AgentExecutionServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run 在创建时已经固化模型配置 ID;执行和模型重连都必须沿用该 ID,
|
||||||
|
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldCreateAgentWithModelBoundToRun() {
|
||||||
|
UUID projectId = UUID.randomUUID();
|
||||||
|
UUID runId = UUID.randomUUID();
|
||||||
|
UUID modelId = UUID.randomUUID();
|
||||||
|
AgentFactory factory = mock(AgentFactory.class);
|
||||||
|
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
|
||||||
|
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
when(handle.adapter()).thenReturn(adapter);
|
||||||
|
when(adapter.run(any())).thenReturn(Flux.empty());
|
||||||
|
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
|
||||||
|
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
|
||||||
|
|
||||||
|
AgentExecutionService service = new AgentExecutionService(
|
||||||
|
new ObjectMapper(),
|
||||||
|
factory,
|
||||||
|
mock(AgentEventService.class),
|
||||||
|
mock(ProjectFileService.class),
|
||||||
|
skillService);
|
||||||
|
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
|
||||||
|
when(project.id()).thenReturn(projectId);
|
||||||
|
when(project.threadId()).thenReturn("project-" + projectId);
|
||||||
|
AgentRunService.RunView run = new AgentRunService.RunView(
|
||||||
|
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null);
|
||||||
|
|
||||||
|
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
|
||||||
|
|
||||||
|
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
|
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,20 +1,87 @@
|
|||||||
package tech.easyflow.manuagent.agent;
|
package tech.easyflow.manuagent.agent;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import io.agentscope.core.model.transport.HttpTransportException;
|
import io.agentscope.core.model.transport.HttpTransportException;
|
||||||
import io.agentscope.core.skill.AgentSkill;
|
import io.agentscope.core.skill.AgentSkill;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import tech.easyflow.manuagent.artifact.ArtifactService;
|
||||||
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
|
import tech.easyflow.manuagent.project.ProjectService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Agent Run 的模型重连边界。
|
* 验证 Agent Run 的模型重连边界。
|
||||||
*/
|
*/
|
||||||
class AgentRunServiceTest {
|
class AgentRunServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run,
|
||||||
|
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() {
|
||||||
|
UUID projectId = UUID.randomUUID();
|
||||||
|
UUID modelId = UUID.randomUUID();
|
||||||
|
UUID currentRunId = UUID.randomUUID();
|
||||||
|
UUID replacementRunId = UUID.randomUUID();
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
ProjectService.ProjectView project = new ProjectService.ProjectView(
|
||||||
|
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
|
||||||
|
AgentRunService.RunView current = new AgentRunService.RunView(
|
||||||
|
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
|
||||||
|
AgentRunService.RunView replacement = new AgentRunService.RunView(
|
||||||
|
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
|
||||||
|
|
||||||
|
AgentRunMapper runMapper = mock(AgentRunMapper.class);
|
||||||
|
AgentRunStore runStore = mock(AgentRunStore.class);
|
||||||
|
ProjectService projectService = mock(ProjectService.class);
|
||||||
|
UserService userService = mock(UserService.class);
|
||||||
|
when(projectService.require(projectId)).thenReturn(project);
|
||||||
|
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
|
||||||
|
when(runStore.latest(projectId)).thenReturn(current);
|
||||||
|
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
|
||||||
|
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
|
||||||
|
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
|
||||||
|
|
||||||
|
AgentRunService service = new AgentRunService(
|
||||||
|
runMapper,
|
||||||
|
new ObjectMapper(),
|
||||||
|
mock(AgentExecutionService.class),
|
||||||
|
mock(AgentOutputService.class),
|
||||||
|
runStore,
|
||||||
|
mock(AgentEventService.class),
|
||||||
|
projectService,
|
||||||
|
mock(ProjectFileService.class),
|
||||||
|
userService,
|
||||||
|
mock(ArtifactService.class),
|
||||||
|
mock(ExecutorService.class),
|
||||||
|
mock(TransactionTemplate.class));
|
||||||
|
|
||||||
|
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
|
||||||
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
|
try {
|
||||||
|
assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement);
|
||||||
|
verify(runMapper).interruptRunning(currentRunId);
|
||||||
|
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
|
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,25 @@ package tech.easyflow.manuagent.project;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
import tech.easyflow.manuagent.config.AppProperties;
|
||||||
|
import tech.easyflow.manuagent.entity.ProjectFileEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证项目工作区文件操作。
|
* 验证项目工作区文件操作。
|
||||||
@@ -31,11 +38,11 @@ class ProjectFileServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldDeleteProjectWorkspace() throws Exception {
|
void shouldDeleteProjectWorkspace() throws Exception {
|
||||||
AppProperties properties = new AppProperties(
|
AppProperties properties = new AppProperties(
|
||||||
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
|
temporaryDirectory, Path.of("dashscope"),
|
||||||
"test-master", "admin", "admin", "https://example.test", "model",
|
"test-master", "admin", "admin",
|
||||||
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
"runtime:test", "bridge", Duration.ofMinutes(1));
|
||||||
ProjectFileService service = new ProjectFileService(
|
ProjectFileService service = new ProjectFileService(
|
||||||
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
|
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
|
||||||
UUID projectId = UUID.randomUUID();
|
UUID projectId = UUID.randomUUID();
|
||||||
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
||||||
Files.createDirectories(file.getParent());
|
Files.createDirectories(file.getParent());
|
||||||
@@ -72,17 +79,50 @@ class ProjectFileServiceTest {
|
|||||||
.isInstanceOf(ApiException.class);
|
.isInstanceOf(ApiException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件列表应保持迁移前 SQL 的字段范围,避免加载存储文件名、摘要和上传人等内部列。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldSelectOnlyFileViewColumnsWhenListing() {
|
||||||
|
ProjectFileMapper mapper = mock(ProjectFileMapper.class);
|
||||||
|
ProjectService projectService = mock(ProjectService.class);
|
||||||
|
ProjectFileEntity file = new ProjectFileEntity();
|
||||||
|
file.setId(UUID.randomUUID());
|
||||||
|
file.setProjectId(UUID.randomUUID());
|
||||||
|
file.setSizeBytes(1L);
|
||||||
|
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
|
||||||
|
ProjectFileService service = new ProjectFileService(
|
||||||
|
mapper, mock(UserService.class), projectService, properties());
|
||||||
|
|
||||||
|
service.list(file.getProjectId());
|
||||||
|
|
||||||
|
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||||
|
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("original_name", "relative_path", "mime_type", "size_bytes", "created_at")
|
||||||
|
.doesNotContain("stored_name", "sha256", "uploaded_by", "updated_at");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建使用临时数据目录的文件服务。
|
* 创建使用临时数据目录的文件服务。
|
||||||
*
|
*
|
||||||
* @return 文件服务
|
* @return 文件服务
|
||||||
*/
|
*/
|
||||||
private ProjectFileService service() {
|
private ProjectFileService service() {
|
||||||
AppProperties properties = new AppProperties(
|
|
||||||
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
|
|
||||||
"test-master", "admin", "admin", "https://example.test", "model",
|
|
||||||
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
|
||||||
return new ProjectFileService(
|
return new ProjectFileService(
|
||||||
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
|
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试统一使用的应用配置。
|
||||||
|
*
|
||||||
|
* @return 指向临时数据目录的配置
|
||||||
|
*/
|
||||||
|
private AppProperties properties() {
|
||||||
|
return new AppProperties(
|
||||||
|
temporaryDirectory, Path.of("dashscope"),
|
||||||
|
"test-master", "admin", "admin",
|
||||||
|
"runtime:test", "bridge", Duration.ofMinutes(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
7
web-ui/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
client
|
||||||
|
*.tsbuildinfo
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
21
web-ui/Dockerfile
Normal file
21
web-ui/Dockerfile
Normal 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
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>智造申报 Agent</title>
|
<title>智造申报 Agent</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
33
web-ui/nginx.conf
Normal file
33
web-ui/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
web-ui/public/favicon.svg
Normal file
4
web-ui/public/favicon.svg
Normal 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 |
138
web-ui/src/pages/ModelsPage.test.ts
Normal file
138
web-ui/src/pages/ModelsPage.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { CircleCheck } from '@element-plus/icons-vue'
|
import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
@@ -19,19 +19,38 @@ interface ModelConfig {
|
|||||||
|
|
||||||
const models = ref<ModelConfig[]>([])
|
const models = ref<ModelConfig[]>([])
|
||||||
const selectedId = ref('')
|
const selectedId = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
const tested = ref(false)
|
const tested = ref(false)
|
||||||
|
const stateChanging = ref(false)
|
||||||
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
||||||
const selected = computed(() => models.value.find(model => model.id === selectedId.value))
|
const selected = computed(() => models.value.find(model => model.id === selectedId.value))
|
||||||
|
const canSave = computed(() => Boolean(
|
||||||
|
form.name.trim() && form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
|
||||||
|
))
|
||||||
|
const canTest = computed(() => Boolean(
|
||||||
|
form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
|
||||||
|
))
|
||||||
|
|
||||||
async function load() {
|
// “连接正常”只证明发起请求时的草稿;任一字段变化后必须重新测试。
|
||||||
|
watch(form, () => { tested.value = false })
|
||||||
|
|
||||||
|
/** 从服务端刷新模型列表,并尽量维持用户当前选中的模型。 */
|
||||||
|
async function load(preferredId?: string) {
|
||||||
models.value = await api<ModelConfig[]>('/api/models')
|
models.value = await api<ModelConfig[]>('/api/models')
|
||||||
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '')
|
const nextId = preferredId
|
||||||
|
|| (models.value.some(model => model.id === selectedId.value) ? selectedId.value : '')
|
||||||
|
|| models.value.find(model => model.defaultModel)?.id
|
||||||
|
|| models.value[0]?.id
|
||||||
|
|| ''
|
||||||
|
if (nextId) select(nextId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将数据库模型投影到编辑表单;API Key 始终保持为空,避免密钥回显。 */
|
||||||
function select(id: string) {
|
function select(id: string) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
|
creating.value = false
|
||||||
const model = models.value.find(item => item.id === id)
|
const model = models.value.find(item => item.id === id)
|
||||||
if (!model) return
|
if (!model) return
|
||||||
let capabilities: { contextWindow?: number } = {}
|
let capabilities: { contextWindow?: number } = {}
|
||||||
@@ -46,32 +65,57 @@ function select(id: string) {
|
|||||||
tested.value = false
|
tested.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 进入新增模式并清空所有可能来自已有模型的可编辑字段。 */
|
||||||
|
function beginCreate() {
|
||||||
|
selectedId.value = ''
|
||||||
|
creating.value = true
|
||||||
|
tested.value = false
|
||||||
|
Object.assign(form, { name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建或更新模型;新增模型的默认选择由后端事务保证。 */
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!canSave.value || saving.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api(`/api/models/${selectedId.value}`, {
|
const path = creating.value ? '/api/models' : `/api/models/${selectedId.value}`
|
||||||
method: 'PUT',
|
const saved = await api<ModelConfig>(path, {
|
||||||
|
method: creating.value ? 'POST' : 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: form.name,
|
name: form.name.trim(),
|
||||||
baseUrl: form.baseUrl,
|
baseUrl: form.baseUrl.trim(),
|
||||||
modelId: form.modelId,
|
modelId: form.modelId.trim(),
|
||||||
apiKey: form.apiKey,
|
apiKey: form.apiKey,
|
||||||
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
|
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
|
||||||
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
|
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
ElMessage.success('已保存')
|
ElMessage.success(creating.value ? '模型已新增' : '配置已保存')
|
||||||
await load()
|
creating.value = false
|
||||||
|
await load(saved.id)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test() {
|
/**
|
||||||
|
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
|
||||||
|
* 测试只验证草稿,不会隐式保存任何配置字段。
|
||||||
|
*/
|
||||||
|
async function testConnection() {
|
||||||
|
if (!canTest.value || testing.value) return
|
||||||
testing.value = true
|
testing.value = true
|
||||||
tested.value = false
|
tested.value = false
|
||||||
try {
|
try {
|
||||||
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' })
|
await api('/api/models/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: selected.value?.id || null,
|
||||||
|
baseUrl: form.baseUrl.trim(),
|
||||||
|
modelId: form.modelId.trim(),
|
||||||
|
apiKey: form.apiKey
|
||||||
|
})
|
||||||
|
})
|
||||||
tested.value = true
|
tested.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '连接失败')
|
ElMessage.error(error instanceof Error ? error.message : '连接失败')
|
||||||
@@ -80,43 +124,139 @@ async function test() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
|
||||||
async function setDefault() {
|
async function setDefault() {
|
||||||
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' })
|
if (!selected.value || !selected.value.enabled || stateChanging.value) return
|
||||||
await load()
|
stateChanging.value = true
|
||||||
|
try {
|
||||||
|
await api(`/api/models/${selected.value.id}/default`, { method: 'POST' })
|
||||||
|
await load(selected.value.id)
|
||||||
|
} finally {
|
||||||
|
stateChanging.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
/** 启停非默认模型;后端会阻止停用仍被运行中任务使用的模型。 */
|
||||||
|
async function toggleEnabled() {
|
||||||
|
if (!selected.value || stateChanging.value) return
|
||||||
|
const enabled = !selected.value.enabled
|
||||||
|
if (!enabled) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`停用“${selected.value.name}”?`, '停用模型', {
|
||||||
|
confirmButtonText: '停用', cancelButtonText: '取消', type: 'warning'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateChanging.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api<ModelConfig>(`/api/models/${selected.value.id}/enabled`, {
|
||||||
|
method: 'PATCH', body: JSON.stringify({ enabled })
|
||||||
|
})
|
||||||
|
ElMessage.success(enabled ? '模型已启用' : '模型已停用')
|
||||||
|
await load(updated.id)
|
||||||
|
} finally {
|
||||||
|
stateChanging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真删除没有历史 Run 引用的非默认模型;常规模型下线优先使用停用。 */
|
||||||
|
async function removeModel() {
|
||||||
|
if (!selected.value || stateChanging.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`永久删除“${selected.value.name}”?`, '删除模型', {
|
||||||
|
confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateChanging.value = true
|
||||||
|
try {
|
||||||
|
await api(`/api/models/${selected.value.id}`, { method: 'DELETE' })
|
||||||
|
ElMessage.success('模型已删除')
|
||||||
|
selectedId.value = ''
|
||||||
|
await load()
|
||||||
|
if (!models.value.length) beginCreate()
|
||||||
|
} finally {
|
||||||
|
stateChanging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
if (!models.value.length) beginCreate()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="settings-page">
|
<section class="settings-page">
|
||||||
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header>
|
<header class="settings-header">
|
||||||
|
<div><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></div>
|
||||||
|
<el-button :icon="Plus" type="primary" @click="beginCreate">新增模型</el-button>
|
||||||
|
</header>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<aside class="settings-list">
|
<aside class="settings-list">
|
||||||
<h2>已配置模型</h2>
|
<h2>已配置模型 <small>{{ models.length }}</small></h2>
|
||||||
|
<div v-if="!models.length" class="model-empty">暂无模型</div>
|
||||||
<button
|
<button
|
||||||
v-for="model in models"
|
v-for="model in models"
|
||||||
:key="model.id"
|
:key="model.id"
|
||||||
:class="{ selected: selectedId === model.id }"
|
class="model-list-item"
|
||||||
|
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
|
||||||
@click="select(model.id)"
|
@click="select(model.id)"
|
||||||
>
|
>
|
||||||
<strong>{{ model.name }}</strong>
|
<strong>{{ model.name }}</strong>
|
||||||
<span>DeepSeek · {{ model.modelId }}</span>
|
<span>OpenAI 兼容 · {{ model.modelId }}</span>
|
||||||
<small><i></i>可用</small>
|
<small :class="{ muted: !model.enabled }">
|
||||||
|
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
<form v-if="selected" class="settings-form" @submit.prevent="save">
|
|
||||||
<div class="form-title"><h2>{{ selected.name }}</h2><el-button v-if="!selected.defaultModel" @click="setDefault">设为默认</el-button><span v-else class="tag blue">默认</span></div>
|
<form v-if="creating || selected" class="settings-form" @submit.prevent="save">
|
||||||
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label>
|
<div class="form-title">
|
||||||
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label>
|
<h2>{{ creating ? '新增模型' : selected?.name }}</h2>
|
||||||
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label>
|
<div v-if="selected" class="model-title-actions">
|
||||||
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label>
|
<span v-if="selected.defaultModel" class="tag blue">默认</span>
|
||||||
|
<el-button v-else :disabled="!selected.enabled" :loading="stateChanging" @click="setDefault">设为默认</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label><span>配置名称</span><el-input v-model="form.name" maxlength="100" /></label>
|
||||||
|
<label><span>服务商</span><el-input model-value="OpenAI 兼容" disabled /></label>
|
||||||
|
<label><span>API 地址</span><el-input v-model="form.baseUrl" maxlength="500" /></label>
|
||||||
|
<label>
|
||||||
|
<span>API Key</span>
|
||||||
|
<el-input
|
||||||
|
v-model="form.apiKey"
|
||||||
|
type="password"
|
||||||
|
maxlength="4096"
|
||||||
|
show-password
|
||||||
|
:placeholder="creating ? '输入 API Key' : selected?.apiKeyHint || '留空保留现有密钥'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label><span>模型 ID</span><el-input v-model="form.modelId" maxlength="255" /></label>
|
||||||
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
|
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
|
||||||
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
|
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
|
||||||
<div class="model-actions">
|
<div class="model-actions">
|
||||||
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button>
|
<el-button native-type="submit" type="primary" :loading="saving" :disabled="!canSave">{{ creating ? '创建模型' : '保存配置' }}</el-button>
|
||||||
<el-button :loading="testing" @click="test">测试连接</el-button>
|
<el-button :loading="testing" :disabled="!canTest" @click="testConnection">测试连接</el-button>
|
||||||
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
|
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
|
||||||
|
<div v-if="selected" class="model-danger-actions">
|
||||||
|
<el-button :loading="stateChanging" :disabled="selected.defaultModel" @click="toggleEnabled">
|
||||||
|
{{ selected.enabled ? '停用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:icon="Delete"
|
||||||
|
circle
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
title="删除模型"
|
||||||
|
:loading="stateChanging"
|
||||||
|
:disabled="selected.defaultModel"
|
||||||
|
@click="removeModel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
432
web-ui/src/pages/ProjectPage.test.ts
Normal file
432
web-ui/src/pages/ProjectPage.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Document, UploadFilled } from '@element-plus/icons-vue'
|
import { Document, UploadFilled } from '@element-plus/icons-vue'
|
||||||
|
import { ArrowDown } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import AgentTimeline from '../components/AgentTimeline.vue'
|
import AgentTimeline from '../components/AgentTimeline.vue'
|
||||||
import MaterialAskCard from '../components/MaterialAskCard.vue'
|
import MaterialAskCard from '../components/MaterialAskCard.vue'
|
||||||
@@ -10,6 +11,26 @@ import { api, streamEvents, type AgentEvent, type Artifact, type PlanView, type
|
|||||||
import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache'
|
import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache'
|
||||||
import { appendUniqueEvents } from '../eventUtils'
|
import { appendUniqueEvents } from '../eventUtils'
|
||||||
|
|
||||||
|
interface ModelOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
modelId: string
|
||||||
|
enabled: boolean
|
||||||
|
defaultModel: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunSummary {
|
||||||
|
status: string
|
||||||
|
modelConfigId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStageDefinitions = [
|
||||||
|
{ key: 'materials', label: '材料检查', description: '核验企业材料' },
|
||||||
|
{ key: 'planning', label: '规划确认', description: '确认建设规划' },
|
||||||
|
{ key: 'writing', label: '申报书编写', description: '生成并校验内容' },
|
||||||
|
{ key: 'delivery', label: '交付', description: '下载最终文件' }
|
||||||
|
] as const
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const emit = defineEmits<{ 'projects-changed': [] }>()
|
const emit = defineEmits<{ 'projects-changed': [] }>()
|
||||||
@@ -26,36 +47,118 @@ const controlLoading = ref(false)
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const historyLoading = ref(true)
|
const historyLoading = ref(true)
|
||||||
const streamError = ref('')
|
const streamError = ref('')
|
||||||
const showBackToBottom = ref(false)
|
const followsLatestOutput = ref(true)
|
||||||
|
const currentModelConfigId = ref('')
|
||||||
|
const modelPickerVisible = ref(false)
|
||||||
|
const modelPickerLoading = ref(false)
|
||||||
|
const modelPickerMode = ref<'resume' | 'switch'>('resume')
|
||||||
|
const selectedModelConfigId = ref('')
|
||||||
|
const modelCatalog = ref<ModelOption[]>([])
|
||||||
|
const selectableModels = ref<ModelOption[]>([])
|
||||||
|
const outputContainer = ref<HTMLElement | null>(null)
|
||||||
let stopStream: (() => void) | null = null
|
let stopStream: (() => void) | null = null
|
||||||
|
let outputResizeObserver: ResizeObserver | null = null
|
||||||
let loadVersion = 0
|
let loadVersion = 0
|
||||||
|
let previousScrollY = 0
|
||||||
|
let hasObservedScrollPosition = false
|
||||||
|
let pendingFollowScrollTarget: number | null = null
|
||||||
|
type UserScrollIntent = 'none' | 'up' | 'down'
|
||||||
|
let userScrollIntent: UserScrollIntent = 'none'
|
||||||
|
let touchStartY: number | null = null
|
||||||
|
let scrollbarDragLastY: number | null = null
|
||||||
|
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
|
||||||
|
let scrollbarDragActive = false
|
||||||
const folderInput = ref<HTMLInputElement | null>(null)
|
const folderInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const LATEST_OUTPUT_THRESHOLD = 48
|
||||||
|
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
|
||||||
|
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
|
||||||
|
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
|
||||||
|
|
||||||
const projectId = computed(() => String(route.params.id || ''))
|
const projectId = computed(() => String(route.params.id || ''))
|
||||||
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
||||||
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
|
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
|
||||||
const running = computed(() => runStatus.value === 'RUNNING')
|
const running = computed(() => runStatus.value === 'RUNNING')
|
||||||
const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
|
const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
|
||||||
|
const modelPickerTitle = computed(() => modelPickerMode.value === 'switch' ? '切换运行模型' : '选择继续运行的模型')
|
||||||
|
const modelPickerConfirmText = computed(() => modelPickerMode.value === 'switch' ? '确认切换' : '继续运行')
|
||||||
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
|
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
|
||||||
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
|
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
|
||||||
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
|
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
|
||||||
}[project.value?.status || 'MATERIAL_CHECK']))
|
}[project.value?.status || 'MATERIAL_CHECK']))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端业务状态投影为稳定的四阶段索引。失败状态通常发生在内容生成阶段,
|
||||||
|
* 因而仍停留在“申报书编写”,方便用户理解失败位置并继续使用原有重试入口。
|
||||||
|
*/
|
||||||
|
const currentStageIndex = computed(() => ({
|
||||||
|
MATERIAL_CHECK: 0,
|
||||||
|
PLANNING: 1,
|
||||||
|
WRITING: 2,
|
||||||
|
FAILED: 2,
|
||||||
|
DELIVERED: 3,
|
||||||
|
ARCHIVED: 3
|
||||||
|
}[project.value?.status || 'MATERIAL_CHECK']))
|
||||||
|
|
||||||
|
/** 为阶段条补充完成、当前和待开始状态;这里只改变展示,不参与后端流程判断。 */
|
||||||
|
const projectStages = computed(() => projectStageDefinitions.map((stage, index) => ({
|
||||||
|
...stage,
|
||||||
|
state: project.value?.status === 'DELIVERED' || project.value?.status === 'ARCHIVED'
|
||||||
|
? 'complete'
|
||||||
|
: index < currentStageIndex.value
|
||||||
|
? 'complete'
|
||||||
|
: index === currentStageIndex.value ? 'current' : 'upcoming'
|
||||||
|
})))
|
||||||
|
|
||||||
|
const currentStageLabel = computed(() => projectStageDefinitions[currentStageIndex.value]?.label || '材料检查')
|
||||||
|
const currentModelLabel = computed(() => {
|
||||||
|
const model = modelCatalog.value.find(item => item.id === currentModelConfigId.value)
|
||||||
|
if (model) return `${model.name} · ${model.modelId}`
|
||||||
|
return currentModelConfigId.value ? '已绑定运行模型' : '尚未选择'
|
||||||
|
})
|
||||||
|
const updatedAtLabel = computed(() => {
|
||||||
|
const updatedAt = project.value?.updatedAt
|
||||||
|
if (!updatedAt) return '--'
|
||||||
|
const value = new Date(updatedAt)
|
||||||
|
return Number.isNaN(value.getTime()) ? '--' : value.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把创建、恢复或切换 Run 的响应同步到页面状态。
|
||||||
|
* 后端返回的 modelConfigId 代表本次 Run 真正绑定的模型,不能继续沿用上一个 Run
|
||||||
|
* 或页面初次加载时的空值;仅在兼容旧响应时才使用调用方提供的兜底模型 ID。
|
||||||
|
*/
|
||||||
|
function syncRunState(run: RunSummary, fallbackModelConfigId = '') {
|
||||||
|
runStatus.value = run.status
|
||||||
|
const modelConfigId = run.modelConfigId || fallbackModelConfigId
|
||||||
|
if (modelConfigId) currentModelConfigId.value = modelConfigId
|
||||||
|
}
|
||||||
|
|
||||||
async function load(id: string) {
|
async function load(id: string) {
|
||||||
const version = ++loadVersion
|
const version = ++loadVersion
|
||||||
historyLoading.value = true
|
historyLoading.value = true
|
||||||
|
followsLatestOutput.value = true
|
||||||
|
previousScrollY = window.scrollY
|
||||||
|
hasObservedScrollPosition = false
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
touchStartY = null
|
||||||
|
scrollbarDragLastY = null
|
||||||
|
scrollbarDragDirection = 'none'
|
||||||
|
scrollbarDragActive = false
|
||||||
stopStream?.()
|
stopStream?.()
|
||||||
stopStream = null
|
stopStream = null
|
||||||
project.value = null
|
project.value = null
|
||||||
events.value = []
|
events.value = []
|
||||||
try {
|
try {
|
||||||
const cached = await readCachedEvents(id).catch(() => [])
|
const cached = await readCachedEvents(id).catch(() => [])
|
||||||
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest] = await Promise.all([
|
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest, loadedModels] = await Promise.all([
|
||||||
api<Project>(`/api/projects/${id}`),
|
api<Project>(`/api/projects/${id}`),
|
||||||
api<ProjectFile[]>(`/api/projects/${id}/files`),
|
api<ProjectFile[]>(`/api/projects/${id}/files`),
|
||||||
api<Artifact[]>(`/api/projects/${id}/artifacts`),
|
api<Artifact[]>(`/api/projects/${id}/artifacts`),
|
||||||
api<PlanView | null>(`/api/projects/${id}/plan`),
|
api<PlanView | null>(`/api/projects/${id}/plan`),
|
||||||
api<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
|
api<(RunSummary & { pendingInterrupt?: string }) | null>(`/api/projects/${id}/runs/latest`),
|
||||||
|
// 模型摘要只服务于右侧信息栏;加载失败不能阻断项目主体和历史事件恢复。
|
||||||
|
api<ModelOption[]>('/api/models').catch(() => [])
|
||||||
])
|
])
|
||||||
const loadedEvents = await fetchMissingEvents(id, cached)
|
const loadedEvents = await fetchMissingEvents(id, cached)
|
||||||
if (version !== loadVersion || id !== projectId.value) return
|
if (version !== loadVersion || id !== projectId.value) return
|
||||||
@@ -65,6 +168,8 @@ async function load(id: string) {
|
|||||||
plan.value = loadedPlan
|
plan.value = loadedPlan
|
||||||
events.value = loadedEvents
|
events.value = loadedEvents
|
||||||
runStatus.value = latest?.status || ''
|
runStatus.value = latest?.status || ''
|
||||||
|
currentModelConfigId.value = latest?.modelConfigId || ''
|
||||||
|
modelCatalog.value = loadedModels
|
||||||
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
|
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
|
||||||
startStream(id, version)
|
startStream(id, version)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,8 +301,8 @@ async function startCheck() {
|
|||||||
if (loading.value || folderUploading.value) return
|
if (loading.value || folderUploading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
|
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
|
||||||
runStatus.value = run.status
|
syncRunState(run)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '启动失败')
|
ElMessage.error(error instanceof Error ? error.message : '启动失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -209,8 +314,8 @@ async function retry() {
|
|||||||
if (plan.value?.status === 'CONFIRMED') {
|
if (plan.value?.status === 'CONFIRMED') {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await api(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
|
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
|
||||||
runStatus.value = 'RUNNING'
|
syncRunState(run)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '重试失败')
|
ElMessage.error(error instanceof Error ? error.message : '重试失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -225,8 +330,8 @@ async function stopRun() {
|
|||||||
if (!running.value || controlLoading.value) return
|
if (!running.value || controlLoading.value) return
|
||||||
controlLoading.value = true
|
controlLoading.value = true
|
||||||
try {
|
try {
|
||||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
|
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
|
||||||
runStatus.value = run.status
|
syncRunState(run)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '停止失败')
|
ElMessage.error(error instanceof Error ? error.message : '停止失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,14 +339,51 @@ async function stopRun() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeRun() {
|
/**
|
||||||
if (!interrupted.value || controlLoading.value) return
|
* 打开恢复或切换模型对话框,并从数据库重新读取当前启用的模型。
|
||||||
|
* 优先保留 Run 已绑定的模型;如果该模型已停用,则退回当前默认模型或首个可用模型。
|
||||||
|
*/
|
||||||
|
async function openModelPicker(mode: 'resume' | 'switch') {
|
||||||
|
if (controlLoading.value || modelPickerLoading.value) return
|
||||||
|
modelPickerMode.value = mode
|
||||||
|
modelPickerLoading.value = true
|
||||||
|
try {
|
||||||
|
const models = await api<ModelOption[]>('/api/models')
|
||||||
|
modelCatalog.value = models
|
||||||
|
selectableModels.value = models.filter(model => model.enabled)
|
||||||
|
if (!selectableModels.value.length) {
|
||||||
|
ElMessage.error('没有可用模型,请先启用模型配置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedModelConfigId.value = selectableModels.value.find(model => model.id === currentModelConfigId.value)?.id
|
||||||
|
|| selectableModels.value.find(model => model.defaultModel)?.id
|
||||||
|
|| selectableModels.value[0]!.id
|
||||||
|
modelPickerVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '模型列表加载失败')
|
||||||
|
} finally {
|
||||||
|
modelPickerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用用户明确选择的模型恢复任务,或中断当前 Run 后创建新的 RESUME Run。
|
||||||
|
* 请求成功后立即更新页面中的 Run 状态和绑定模型,事件流随后会补齐完整审计事件。
|
||||||
|
*/
|
||||||
|
async function confirmModelSelection() {
|
||||||
|
if (!selectedModelConfigId.value || controlLoading.value) return
|
||||||
controlLoading.value = true
|
controlLoading.value = true
|
||||||
try {
|
try {
|
||||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' })
|
const endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
|
||||||
runStatus.value = run.status
|
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ modelConfigId: selectedModelConfigId.value })
|
||||||
|
})
|
||||||
|
syncRunState(run, selectedModelConfigId.value)
|
||||||
|
modelPickerVisible.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '继续失败')
|
const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : fallback)
|
||||||
} finally {
|
} finally {
|
||||||
controlLoading.value = false
|
controlLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -289,14 +431,14 @@ async function confirmPlan(value: Record<string, unknown>) {
|
|||||||
if (!plan.value || loading.value) return
|
if (!plan.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api<{ plan: PlanView; run: { status: string } }>(`/api/projects/${projectId.value}/plan/confirm`, {
|
const result = await api<{ plan: PlanView; run: RunSummary }>(`/api/projects/${projectId.value}/plan/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ planId: plan.value.id, plan: value })
|
body: JSON.stringify({ planId: plan.value.id, plan: value })
|
||||||
})
|
})
|
||||||
localStorage.removeItem(`plan-draft:${plan.value.id}`)
|
localStorage.removeItem(`plan-draft:${plan.value.id}`)
|
||||||
plan.value = result.plan
|
plan.value = result.plan
|
||||||
pendingAsk.value = null
|
pendingAsk.value = null
|
||||||
runStatus.value = result.run.status
|
syncRunState(result.run)
|
||||||
await refreshProject()
|
await refreshProject()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
|
ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
|
||||||
@@ -309,12 +451,12 @@ async function confirmMaterials(value: Record<string, unknown>) {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/material/confirm`, {
|
const run = await api<RunSummary>(`/api/projects/${projectId.value}/material/confirm`, {
|
||||||
method: 'POST', body: JSON.stringify(value)
|
method: 'POST', body: JSON.stringify(value)
|
||||||
})
|
})
|
||||||
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
|
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
|
||||||
pendingAsk.value = null
|
pendingAsk.value = null
|
||||||
runStatus.value = run.status
|
syncRunState(run)
|
||||||
await refreshProject()
|
await refreshProject()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '材料确认失败')
|
ElMessage.error(error instanceof Error ? error.message : '材料确认失败')
|
||||||
@@ -341,25 +483,239 @@ async function refreshArtifacts(id = projectId.value, version = loadVersion) {
|
|||||||
if (version === loadVersion && id === projectId.value) artifacts.value = value
|
if (version === loadVersion && id === projectId.value) artifacts.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
|
||||||
|
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
|
||||||
|
*/
|
||||||
function updateScrollState() {
|
function updateScrollState() {
|
||||||
showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240
|
const currentScrollY = window.scrollY
|
||||||
|
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||||
|
const scrollingUp = hasObservedScrollPosition && currentScrollY < previousScrollY
|
||||||
|
const completingFollowScroll = pendingFollowScrollTarget !== null
|
||||||
|
|
||||||
|
if (completingFollowScroll) {
|
||||||
|
// 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
|
||||||
|
// 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
|
||||||
|
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||||
|
if (reachedRequestedTarget) {
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
followsLatestOutput.value = true
|
||||||
|
}
|
||||||
|
} else if (scrollingUp && userScrollIntent !== 'down') {
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
|
||||||
|
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
||||||
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
}
|
||||||
|
previousScrollY = currentScrollY
|
||||||
|
hasObservedScrollPosition = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将窗口移动到当前文档底部,并记录本次程序滚动的目标位置。
|
||||||
|
*
|
||||||
|
* <p>浏览器派发 scroll 事件晚于 scrollTo 调用,大文档还可能先派发一个或多个
|
||||||
|
* 尚未到达底部的中间事件。目标位置必须单独保存,不能写入 previousScrollY,
|
||||||
|
* 否则中间坐标会因为小于目标值而被误判为用户向上滚动。</p>
|
||||||
|
*/
|
||||||
|
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||||
|
const scrollHeight = document.documentElement.scrollHeight
|
||||||
|
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
|
||||||
|
// 所有程序滚动(箭头点击、事件更新、ResizeObserver)统一只在真正抵达目标时
|
||||||
|
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
|
||||||
|
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||||
|
? null
|
||||||
|
: targetScrollY
|
||||||
|
hasObservedScrollPosition = true
|
||||||
|
const options: ScrollToOptions = { top: scrollHeight }
|
||||||
|
if (behavior) options.behavior = behavior
|
||||||
|
window.scrollTo(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
touchStartY = null
|
||||||
|
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
|
||||||
|
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
|
||||||
|
moveViewportToLatestOutput('auto')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
|
||||||
|
function isInteractiveTarget(target: EventTarget | null) {
|
||||||
|
const element = target instanceof Element ? target : null
|
||||||
|
return Boolean(element && (
|
||||||
|
(element instanceof HTMLElement && element.isContentEditable)
|
||||||
|
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
|
||||||
|
function restoreFollowIfAtLatest() {
|
||||||
|
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||||
|
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||||
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
|
||||||
|
*
|
||||||
|
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
|
||||||
|
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
|
||||||
|
*/
|
||||||
|
function handleUserWheel(event: WheelEvent) {
|
||||||
|
if (isInteractiveTarget(event.target)) return
|
||||||
|
if (event.deltaY !== 0) {
|
||||||
|
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
|
||||||
|
// 事件可能在用户上滚后再次把页面状态恢复到底部。
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
}
|
||||||
|
if (event.deltaY < 0) {
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
} else if (event.deltaY > 0) {
|
||||||
|
userScrollIntent = 'down'
|
||||||
|
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
|
||||||
|
restoreFollowIfAtLatest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
|
||||||
|
function handleUserScrollbarDrag(event: MouseEvent) {
|
||||||
|
const scrollbarStart = document.documentElement.clientWidth
|
||||||
|
if (event.clientX >= scrollbarStart) {
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
scrollbarDragLastY = event.clientY
|
||||||
|
scrollbarDragDirection = 'none'
|
||||||
|
scrollbarDragActive = true
|
||||||
|
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
|
||||||
|
function handleUserScrollbarMove(event: MouseEvent) {
|
||||||
|
if (!scrollbarDragActive || scrollbarDragLastY === null) return
|
||||||
|
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
|
||||||
|
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
|
||||||
|
scrollbarDragLastY = event.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
|
||||||
|
function handleUserMouseUp() {
|
||||||
|
if (!scrollbarDragActive) return
|
||||||
|
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||||
|
scrollbarDragActive = false
|
||||||
|
scrollbarDragLastY = null
|
||||||
|
const draggedUp = scrollbarDragDirection === 'up'
|
||||||
|
scrollbarDragDirection = 'none'
|
||||||
|
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||||
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
|
||||||
|
function handleUserTouchStart(event: TouchEvent) {
|
||||||
|
touchStartY = event.touches[0]?.clientY ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
|
||||||
|
function handleUserTouchMove(event: TouchEvent) {
|
||||||
|
if (isInteractiveTarget(event.target)) return
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
const currentY = event.touches[0]?.clientY
|
||||||
|
if (touchStartY === null || currentY === undefined) {
|
||||||
|
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pageScrollDelta = touchStartY - currentY
|
||||||
|
if (pageScrollDelta < 0) {
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
} else if (pageScrollDelta > 0) {
|
||||||
|
userScrollIntent = 'down'
|
||||||
|
restoreFollowIfAtLatest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
|
||||||
|
function handleUserTouchEnd() {
|
||||||
|
touchStartY = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
|
||||||
|
function handleUserKeydown(event: KeyboardEvent) {
|
||||||
|
if (isInteractiveTarget(event.target)) return
|
||||||
|
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
|
||||||
|
followsLatestOutput.value = false
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
userScrollIntent = 'up'
|
||||||
|
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
|
||||||
|
pendingFollowScrollTarget = null
|
||||||
|
userScrollIntent = 'down'
|
||||||
|
restoreFollowIfAtLatest()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||||
watch(() => events.value.length, async () => {
|
watch(() => events.value.length, async () => {
|
||||||
const followsTail = !showBackToBottom.value
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight })
|
// nextTick 后重新读取实时状态,确保用户在本轮渲染期间向上滚动也能立即中止跟随。
|
||||||
|
if (followsLatestOutput.value) moveViewportToLatestOutput()
|
||||||
|
})
|
||||||
|
watch(outputContainer, (current, previous) => {
|
||||||
|
if (!outputResizeObserver) return
|
||||||
|
if (previous) outputResizeObserver.unobserve(previous)
|
||||||
|
if (current) outputResizeObserver.observe(current)
|
||||||
|
}, { flush: 'post' })
|
||||||
|
onMounted(() => {
|
||||||
|
/**
|
||||||
|
* Markstream 的平滑流式渲染会在 Vue nextTick 结束后继续逐帧增加内容高度。
|
||||||
|
* 监听真实输出容器的尺寸变化,才能在同一批事件的渲染动画期间持续贴住底部。
|
||||||
|
*/
|
||||||
|
outputResizeObserver = new ResizeObserver(() => {
|
||||||
|
if (followsLatestOutput.value) moveViewportToLatestOutput()
|
||||||
|
})
|
||||||
|
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateScrollState, { passive: true })
|
||||||
|
window.addEventListener('wheel', handleUserWheel, { passive: true })
|
||||||
|
window.addEventListener('mousedown', handleUserScrollbarDrag)
|
||||||
|
window.addEventListener('mousemove', handleUserScrollbarMove)
|
||||||
|
window.addEventListener('mouseup', handleUserMouseUp)
|
||||||
|
window.addEventListener('touchstart', handleUserTouchStart, { passive: true })
|
||||||
|
window.addEventListener('touchmove', handleUserTouchMove, { passive: true })
|
||||||
|
window.addEventListener('touchend', handleUserTouchEnd, { passive: true })
|
||||||
|
window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true })
|
||||||
|
window.addEventListener('keydown', handleUserKeydown)
|
||||||
|
updateScrollState()
|
||||||
})
|
})
|
||||||
onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true }))
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
loadVersion++
|
loadVersion++
|
||||||
stopStream?.()
|
stopStream?.()
|
||||||
|
outputResizeObserver?.disconnect()
|
||||||
|
outputResizeObserver = null
|
||||||
window.removeEventListener('scroll', updateScrollState)
|
window.removeEventListener('scroll', updateScrollState)
|
||||||
|
window.removeEventListener('wheel', handleUserWheel)
|
||||||
|
window.removeEventListener('mousedown', handleUserScrollbarDrag)
|
||||||
|
window.removeEventListener('mousemove', handleUserScrollbarMove)
|
||||||
|
window.removeEventListener('mouseup', handleUserMouseUp)
|
||||||
|
window.removeEventListener('touchstart', handleUserTouchStart)
|
||||||
|
window.removeEventListener('touchmove', handleUserTouchMove)
|
||||||
|
window.removeEventListener('touchend', handleUserTouchEnd)
|
||||||
|
window.removeEventListener('touchcancel', handleUserTouchEnd)
|
||||||
|
window.removeEventListener('keydown', handleUserKeydown)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -368,59 +724,145 @@ onBeforeUnmount(() => {
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
|
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
|
||||||
<div class="run-actions">
|
<div class="run-actions">
|
||||||
|
<el-button v-if="running" text :loading="modelPickerLoading" @click="openModelPicker('switch')">切换模型</el-button>
|
||||||
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
|
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
|
||||||
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button>
|
<el-button v-else-if="interrupted" type="primary" plain :loading="modelPickerLoading" @click="openModelPicker('resume')">继续</el-button>
|
||||||
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
|
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="work-scroll">
|
<el-dialog v-model="modelPickerVisible" :title="modelPickerTitle" width="420px" :close-on-click-modal="false">
|
||||||
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
|
<label class="model-picker-field">
|
||||||
<section v-else-if="!events.length" class="material-start">
|
<span>运行模型</span>
|
||||||
<h2>企业材料</h2>
|
<el-select v-model="selectedModelConfigId" placeholder="选择模型" style="width: 100%">
|
||||||
<el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box">
|
<el-option
|
||||||
<el-icon><UploadFilled /></el-icon>
|
v-for="model in selectableModels"
|
||||||
<p>拖入企业材料,或点击上传</p>
|
:key="model.id"
|
||||||
<small>支持 PDF、DOCX、XLSX、PPTX、图片</small>
|
:label="`${model.name} · ${model.modelId}${model.defaultModel ? '(默认)' : ''}`"
|
||||||
</el-upload>
|
:value="model.id"
|
||||||
<div class="folder-upload">
|
/>
|
||||||
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button>
|
</el-select>
|
||||||
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" />
|
</label>
|
||||||
</div>
|
<template #footer>
|
||||||
<div v-if="files.length" class="file-chips">
|
<el-button :disabled="controlLoading" @click="modelPickerVisible = false">取消</el-button>
|
||||||
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span>
|
<el-button type="primary" :loading="controlLoading" :disabled="!selectedModelConfigId" @click="confirmModelSelection">
|
||||||
<small v-if="files.length > 12">共 {{ files.length }} 个文件</small>
|
{{ modelPickerConfirmText }}
|
||||||
</div>
|
</el-button>
|
||||||
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button>
|
</template>
|
||||||
</section>
|
</el-dialog>
|
||||||
|
|
||||||
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" />
|
<div class="project-workspace">
|
||||||
<MaterialAskCard
|
<main class="workspace-main">
|
||||||
v-if="waitingMaterials"
|
<ol class="project-stage-bar" aria-label="项目执行阶段">
|
||||||
:ask="pendingAsk as any"
|
<li
|
||||||
:loading="loading"
|
v-for="(stage, index) in projectStages"
|
||||||
:upload-file="uploadFile"
|
:key="stage.key"
|
||||||
@confirm="confirmMaterials"
|
class="project-stage"
|
||||||
/>
|
:class="`is-${stage.state}`"
|
||||||
<PlanCard
|
:aria-current="stage.state === 'current' ? 'step' : undefined"
|
||||||
v-if="waitingPlan"
|
>
|
||||||
:plan-id="plan!.id"
|
<span class="stage-marker" aria-hidden="true">{{ stage.state === 'complete' ? '✓' : index + 1 }}</span>
|
||||||
:plan="plan!.plan"
|
<span class="stage-copy"><strong>{{ stage.label }}</strong><small>{{ stage.description }}</small></span>
|
||||||
:loading="loading"
|
</li>
|
||||||
@confirm="confirmPlan"
|
</ol>
|
||||||
/>
|
|
||||||
|
|
||||||
<section v-if="artifacts.length" class="artifact-card">
|
<div ref="outputContainer" class="work-scroll">
|
||||||
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
|
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
|
||||||
<Document />
|
<section v-else-if="!events.length" class="material-start">
|
||||||
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div>
|
<h2>企业材料</h2>
|
||||||
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a>
|
<el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box">
|
||||||
|
<el-icon><UploadFilled /></el-icon>
|
||||||
|
<p>拖入企业材料,或点击上传</p>
|
||||||
|
<small>支持 PDF、DOCX、XLSX、PPTX、图片</small>
|
||||||
|
</el-upload>
|
||||||
|
<div class="folder-upload">
|
||||||
|
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button>
|
||||||
|
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" />
|
||||||
|
</div>
|
||||||
|
<div v-if="files.length" class="file-chips">
|
||||||
|
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span>
|
||||||
|
<small v-if="files.length > 12">共 {{ files.length }} 个文件</small>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- AgentTimeline 继续消费原始流式事件,视觉布局不会替换或截断事件内容。 -->
|
||||||
|
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" />
|
||||||
|
<MaterialAskCard
|
||||||
|
v-if="waitingMaterials"
|
||||||
|
:ask="pendingAsk as any"
|
||||||
|
:loading="loading"
|
||||||
|
:upload-file="uploadFile"
|
||||||
|
@confirm="confirmMaterials"
|
||||||
|
/>
|
||||||
|
<PlanCard
|
||||||
|
v-if="waitingPlan"
|
||||||
|
:plan-id="plan!.id"
|
||||||
|
:plan="plan!.plan"
|
||||||
|
:loading="loading"
|
||||||
|
@confirm="confirmPlan"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-if="artifacts.length" class="artifact-card">
|
||||||
|
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
|
||||||
|
<Document />
|
||||||
|
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div>
|
||||||
|
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
|
||||||
|
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断,点击重连</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</main>
|
||||||
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
|
|
||||||
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断,点击重连</button>
|
<aside class="project-context-panel" aria-label="项目上下文">
|
||||||
|
<section class="context-card stage-context-card">
|
||||||
|
<h2>当前阶段</h2>
|
||||||
|
<div class="context-stage-summary">
|
||||||
|
<span class="context-stage-index">{{ currentStageIndex + 1 }}</span>
|
||||||
|
<div><strong>{{ currentStageLabel }}</strong><small>{{ statusLabel }}</small></div>
|
||||||
|
</div>
|
||||||
|
<dl class="context-definition-list">
|
||||||
|
<div><dt>最近更新</dt><dd>{{ updatedAtLabel }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="context-card">
|
||||||
|
<div class="context-card-heading"><h2>当前模型</h2><span v-if="running" class="context-status">使用中</span></div>
|
||||||
|
<strong class="current-model-name">{{ currentModelLabel }}</strong>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="context-card">
|
||||||
|
<div class="context-card-heading"><h2>企业材料</h2><span>{{ files.length }} 个文件</span></div>
|
||||||
|
<ul v-if="files.length" class="context-file-list">
|
||||||
|
<li v-for="file in files.slice(0, 4)" :key="file.id"><Document /><span :title="file.relativePath">{{ file.name }}</span></li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="context-empty">尚未上传材料</p>
|
||||||
|
<small v-if="files.length > 4" class="context-more">另有 {{ files.length - 4 }} 个文件</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="context-card">
|
||||||
|
<div class="context-card-heading"><h2>生成文件</h2><span>{{ artifacts.length }} 个文件</span></div>
|
||||||
|
<ul v-if="artifacts.length" class="context-file-list artifact-context-list">
|
||||||
|
<li v-for="artifact in artifacts.slice(0, 3)" :key="artifact.id">
|
||||||
|
<Document />
|
||||||
|
<a :href="`/api/artifacts/${artifact.id}/download`" :title="artifact.name">{{ artifact.name }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="context-empty">完成后将在这里显示交付物</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom">↓</button>
|
<button
|
||||||
|
v-if="!followsLatestOutput"
|
||||||
|
class="back-to-bottom"
|
||||||
|
type="button"
|
||||||
|
aria-label="转到最新输出"
|
||||||
|
title="转到最新输出"
|
||||||
|
@click="scrollToBottom"
|
||||||
|
>
|
||||||
|
<ArrowDown aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="empty-main">新建或选择一个项目</section>
|
<section v-else class="empty-main">新建或选择一个项目</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
background: #fff;
|
background: #f6f7f9;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
--blue: #0f5df5;
|
--blue: #1769e8;
|
||||||
--blue-soft: #f2f7ff;
|
--blue-soft: #eef5ff;
|
||||||
--muted: #667085;
|
--muted: #667085;
|
||||||
--line: #e8edf5;
|
--line: #e3e8ef;
|
||||||
--green: #087d41;
|
--green: #087d41;
|
||||||
|
--surface: #fff;
|
||||||
|
--canvas: #f6f7f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -15,16 +17,16 @@ body { margin: 0; min-width: 0; min-height: 100vh; }
|
|||||||
button, input, textarea { font: inherit; }
|
button, input, textarea { font: inherit; }
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
.app-shell { min-height: 100vh; display: flex; background: #fff; }
|
.app-shell { min-height: 100vh; display: flex; background: var(--canvas); }
|
||||||
.rail { width: 84px; flex: 0 0 84px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 10px; gap: 18px; }
|
.rail { width: 76px; flex: 0 0 76px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 8px; gap: 16px; background: var(--surface); }
|
||||||
.brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; }
|
.brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; }
|
||||||
.brand svg { width: 24px; }
|
.brand svg { width: 24px; }
|
||||||
.rail a { width: 64px; height: 66px; border-radius: 9px; color: #405170; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 15px; }
|
.rail a { width: 60px; height: 62px; border-radius: 8px; color: #526078; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 14px; }
|
||||||
.rail a svg { width: 24px; height: 24px; }
|
.rail a svg { width: 24px; height: 24px; }
|
||||||
.rail a.active { background: var(--blue); color: #fff; }
|
.rail a.active { background: var(--blue); color: #fff; }
|
||||||
.rail .brand + a { margin-top: 6px; }
|
.rail .brand + a { margin-top: 6px; }
|
||||||
|
|
||||||
.project-list { width: 276px; flex: 0 0 276px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 16px; background: #fff; }
|
.project-list { width: 264px; flex: 0 0 264px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 14px; background: var(--surface); }
|
||||||
.aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
|
.aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
|
||||||
.aside-title h2 { font-size: 20px; margin: 0; }
|
.aside-title h2 { font-size: 20px; margin: 0; }
|
||||||
.icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; }
|
.icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; }
|
||||||
@@ -34,16 +36,53 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.project-item time { color: #697794; font-size: 13px; }
|
.project-item time { color: #697794; font-size: 13px; }
|
||||||
.project-item.selected { background: #edf4ff; color: var(--blue); }
|
.project-item.selected { background: #edf4ff; color: var(--blue); }
|
||||||
.aside-empty { padding: 20px 12px; color: #98a2b3; }
|
.aside-empty { padding: 20px 12px; color: #98a2b3; }
|
||||||
.main-view { min-width: 0; flex: 1; }
|
.main-view { min-width: 0; flex: 1; background: var(--surface); }
|
||||||
|
|
||||||
.project-page { min-height: 100vh; display: flex; flex-direction: column; }
|
.project-page { min-height: 100vh; display: flex; flex-direction: column; background: var(--canvas); }
|
||||||
.page-header { height: 80px; flex: 0 0 80px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 32px; background: rgba(255,255,255,.95); position: sticky; top: 0; z-index: 3; }
|
.page-header { height: 72px; flex: 0 0 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); position: sticky; top: 0; z-index: 3; }
|
||||||
.page-heading, .run-actions { display: flex; align-items: center; gap: 12px; }
|
.page-heading, .run-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
.page-header h1 { margin: 0; font-size: 25px; letter-spacing: -.02em; }
|
.page-header h1 { margin: 0; font-size: 24px; letter-spacing: -.02em; }
|
||||||
.tag { display: inline-flex; align-items: center; height: 30px; padding: 0 11px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 14px; font-weight: 500; white-space: nowrap; }
|
.tag { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||||
.tag.blue { color: var(--blue); border-color: #9dbdff; }
|
.tag.blue { color: var(--blue); border-color: #9dbdff; }
|
||||||
.tag.green { color: var(--green); border-color: #9be0bd; }
|
.tag.green { color: var(--green); border-color: #9be0bd; }
|
||||||
.work-scroll { width: min(920px, calc(100% - 64px)); margin: 0 auto; padding: 32px 0 72px; }
|
.project-workspace { width: min(1280px, 100%); margin: 0 auto; padding: 24px; display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; align-items: start; }
|
||||||
|
.workspace-main { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
|
||||||
|
.project-stage-bar { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; padding: 20px 24px; border-bottom: 1px solid var(--line); list-style: none; }
|
||||||
|
.project-stage { min-width: 0; position: relative; display: flex; align-items: center; gap: 10px; color: #8a94a6; }
|
||||||
|
.project-stage:not(:last-child)::after { content: ""; height: 1px; position: absolute; top: 15px; left: 42px; right: 12px; background: #dce2eb; }
|
||||||
|
.project-stage.is-complete:not(:last-child)::after { background: #8fb6f4; }
|
||||||
|
.stage-marker { width: 30px; height: 30px; flex: 0 0 30px; position: relative; z-index: 1; display: grid; place-items: center; border: 1px solid #cfd7e4; border-radius: 50%; background: #f8fafc; color: #68758a; font-size: 13px; font-weight: 700; }
|
||||||
|
.project-stage.is-complete .stage-marker { border-color: #b8e0cc; background: #e9f7f0; color: var(--green); }
|
||||||
|
.project-stage.is-current .stage-marker { border-color: var(--blue); background: var(--blue); color: #fff; }
|
||||||
|
.stage-copy { min-width: 0; position: relative; z-index: 1; display: flex; flex-direction: column; gap: 3px; padding-right: 10px; background: var(--surface); }
|
||||||
|
.stage-copy strong { overflow: hidden; color: #4f5d73; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.stage-copy small { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.project-stage.is-current .stage-copy strong { color: #172033; }
|
||||||
|
.project-stage.is-complete .stage-copy strong { color: #344054; }
|
||||||
|
.work-scroll { width: min(900px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 72px; }
|
||||||
|
.project-context-panel { min-width: 0; position: sticky; top: 96px; display: grid; gap: 12px; }
|
||||||
|
.context-card { padding: 18px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
||||||
|
.context-card h2 { margin: 0; color: #253047; font-size: 15px; }
|
||||||
|
.context-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.context-card-heading > span, .context-status { color: #768399; font-size: 12px; white-space: nowrap; }
|
||||||
|
.context-status { padding: 3px 7px; border-radius: 4px; background: #e9f7f0; color: var(--green); }
|
||||||
|
.context-stage-summary { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||||
|
.context-stage-index { width: 28px; height: 28px; flex: 0 0 28px; display: grid; place-items: center; border-radius: 50%; background: var(--blue); color: #fff; font-size: 13px; font-weight: 700; }
|
||||||
|
.context-stage-summary > div { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.context-stage-summary strong { color: #202b3f; font-size: 14px; }
|
||||||
|
.context-stage-summary small { color: var(--blue); font-size: 12px; }
|
||||||
|
.context-definition-list { margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||||
|
.context-definition-list > div { display: flex; justify-content: space-between; gap: 12px; color: #748096; font-size: 12px; }
|
||||||
|
.context-definition-list dt, .context-definition-list dd { margin: 0; }
|
||||||
|
.context-definition-list dd { overflow: hidden; color: #47546a; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.current-model-name { display: block; overflow: hidden; color: #344054; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.context-file-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.context-file-list li { min-width: 0; display: flex; align-items: center; gap: 8px; color: #536078; font-size: 13px; }
|
||||||
|
.context-file-list svg { width: 16px; flex: 0 0 16px; color: #728099; }
|
||||||
|
.context-file-list span, .context-file-list a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.artifact-context-list a { color: var(--blue); }
|
||||||
|
.context-empty { margin: 0; color: #8a94a6; font-size: 13px; line-height: 1.6; }
|
||||||
|
.context-more { display: block; margin-top: 12px; color: #768399; }
|
||||||
.history-loading { margin-top: 24vh; text-align: center; color: var(--muted); }
|
.history-loading { margin-top: 24vh; text-align: center; color: var(--muted); }
|
||||||
|
|
||||||
.material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; }
|
.material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; }
|
||||||
@@ -59,11 +98,11 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.file-chips svg { width: 16px; color: var(--blue); }
|
.file-chips svg { width: 16px; color: var(--blue); }
|
||||||
.file-chips > small { align-self: center; color: var(--muted); }
|
.file-chips > small { align-self: center; color: var(--muted); }
|
||||||
|
|
||||||
.timeline { display: flex; flex-direction: column; gap: 12px; }
|
.timeline { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; }
|
.load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; }
|
||||||
.load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); }
|
.load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); }
|
||||||
.flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; }
|
.flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; }
|
||||||
.flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; }
|
.flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
||||||
.flow-message > .flow-content { grid-column: 2; }
|
.flow-message > .flow-content { grid-column: 2; }
|
||||||
.flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; }
|
.flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; }
|
||||||
.agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; }
|
.agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; }
|
||||||
@@ -89,7 +128,9 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; }
|
.agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; }
|
.agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; }
|
||||||
.agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
.agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
||||||
.activity-row { color: #778195; font-size: 13px; border: 0; }
|
.activity-row { color: #6f7b90; font-size: 13px; border: 0; }
|
||||||
|
.flow-tool .activity-row { padding: 9px 12px; border: 1px solid var(--line); border-radius: 7px; background: #fafbfc; }
|
||||||
|
.flow-tool .activity-row summary { width: 100%; }
|
||||||
.activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; }
|
.activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; }
|
||||||
.activity-row summary::-webkit-details-marker { display: none; }
|
.activity-row summary::-webkit-details-marker { display: none; }
|
||||||
.activity-row summary::after { content: "›"; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; }
|
.activity-row summary::after { content: "›"; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; }
|
||||||
@@ -107,15 +148,15 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
|
.finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
|
||||||
@keyframes activity-shimmer { to { background-position: -220% 0; } }
|
@keyframes activity-shimmer { to { background-position: -220% 0; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } }
|
||||||
.notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-radius: 8px; background: #f6f9ff; }
|
.notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border: 1px solid #dce5f2; border-radius: 8px; background: #f6f9ff; }
|
||||||
.notice-row svg { width: 18px; }
|
.notice-row svg { width: 18px; }
|
||||||
.notice-row.success { color: #087d41; }
|
.notice-row.success { color: #087d41; }
|
||||||
.notice-row.error { color: #c53131; background: #fff7f7; }
|
.notice-row.error { color: #c53131; background: #fff7f7; }
|
||||||
.notice-row.info { color: #45658f; background: #f7f9fc; }
|
.notice-row.info { color: #45658f; background: #f7f9fc; }
|
||||||
.pending { opacity: .9; }
|
.pending { opacity: .9; }
|
||||||
|
|
||||||
.ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #f6f9ff; box-shadow: inset 0 0 0 1px #d7e4fb; }
|
.ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #fffaf2; box-shadow: inset 0 0 0 1px #f1ca8b; }
|
||||||
.ask-card h3 { color: var(--blue); margin: 0 0 5px; font-size: 20px; }
|
.ask-card h3 { color: #28354a; margin: 0 0 5px; font-size: 19px; }
|
||||||
.ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; }
|
.ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; }
|
||||||
.plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
.plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||||
.plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); }
|
.plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); }
|
||||||
@@ -127,7 +168,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; }
|
.plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; }
|
||||||
.plan-note { display: block; margin-bottom: 16px; }
|
.plan-note { display: block; margin-bottom: 16px; }
|
||||||
.plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; }
|
.plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; }
|
||||||
.ask-actions { display: flex; gap: 8px; }
|
.ask-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
.material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
.material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||||
.material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
.material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
||||||
.material-item:last-child { border-bottom: 0; }
|
.material-item:last-child { border-bottom: 0; }
|
||||||
@@ -141,7 +182,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.material-clear { margin: 0; padding: 16px; color: var(--green); }
|
.material-clear { margin: 0; padding: 16px; color: var(--green); }
|
||||||
.material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; }
|
.material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; }
|
||||||
.material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
.material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
||||||
.artifact-card { margin: 28px 0 min(28vh, 280px) 60px; border-radius: 8px; overflow: hidden; box-shadow: inset 0 0 0 1px #dce4ef; }
|
.artifact-card { margin: 28px 0 72px 60px; border-radius: 8px; overflow: hidden; background: var(--surface); box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||||
.artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); }
|
.artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); }
|
||||||
.artifact-row:last-child { border: 0; }
|
.artifact-row:last-child { border: 0; }
|
||||||
.artifact-row > svg { width: 25px; color: var(--blue); }
|
.artifact-row > svg { width: 25px; color: var(--blue); }
|
||||||
@@ -150,30 +191,42 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.artifact-row a { color: var(--blue); }
|
.artifact-row a { color: var(--blue); }
|
||||||
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
|
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
|
||||||
.retry-button { display: block; margin: 28px auto 0; }
|
.retry-button { display: block; margin: 28px auto 0; }
|
||||||
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; }
|
.back-to-bottom { position: fixed; left: 50%; bottom: 28px; z-index: 4; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); transform: translateX(-50%); cursor: pointer; }
|
||||||
|
.back-to-bottom svg { width: 20px; height: 20px; }
|
||||||
|
.back-to-bottom:hover { background: #f4f7fb; }
|
||||||
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||||||
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
|
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
|
||||||
|
.model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }
|
||||||
|
.model-picker-field > span { color: #53617b; font-size: 14px; }
|
||||||
|
|
||||||
.settings-page { min-height: 100vh; padding: 28px 42px; }
|
.settings-page { min-height: 100vh; padding: 28px 42px; }
|
||||||
.settings-page > header h1 { margin: 0; font-size: 28px; }
|
.settings-page > header h1 { margin: 0; font-size: 28px; }
|
||||||
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
|
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
|
||||||
|
.settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||||
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
|
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
|
||||||
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
|
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
|
||||||
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
|
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
|
||||||
|
.settings-list h2 small { margin-left: 6px; color: #7b879b; font-size: 13px; font-weight: 500; }
|
||||||
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
|
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
|
||||||
.settings-list button.selected { background: #edf4ff; color: var(--blue); }
|
.settings-list button.selected { background: #edf4ff; color: var(--blue); }
|
||||||
|
.settings-list button.disabled strong, .settings-list button.disabled span { color: #8b96a8; }
|
||||||
.settings-list button strong, .settings-list button span { grid-column: 1; }
|
.settings-list button strong, .settings-list button span { grid-column: 1; }
|
||||||
.settings-list button span { margin-top: 8px; color: #5f6d86; }
|
.settings-list button span { margin-top: 8px; color: #5f6d86; }
|
||||||
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
|
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
|
||||||
|
.settings-list button small.muted { color: #8b96a8; }
|
||||||
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
|
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
|
||||||
|
.settings-list button small.muted i { background: #9ca6b5; }
|
||||||
|
.model-empty { min-height: 160px; display: grid; place-items: center; color: #8995a8; border: 1px dashed #d9e0ea; border-radius: 7px; }
|
||||||
.settings-form { padding-left: 28px; }
|
.settings-form { padding-left: 28px; }
|
||||||
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||||
.form-title h2 { margin: 0; font-size: 22px; }
|
.form-title h2 { margin: 0; font-size: 22px; }
|
||||||
|
.model-title-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
|
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
|
||||||
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
|
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
|
||||||
.capability-row div { display: flex; gap: 8px; }
|
.capability-row div { display: flex; gap: 8px; }
|
||||||
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
|
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
|
||||||
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
|
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
|
||||||
|
.model-danger-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
||||||
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.connection-ok svg { width: 18px; }
|
.connection-ok svg { width: 18px; }
|
||||||
|
|
||||||
@@ -212,8 +265,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; }
|
.el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; }
|
||||||
.el-dialog { border-radius: 12px; }
|
.el-dialog { border-radius: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.project-workspace { max-width: 960px; grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.project-context-panel { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.project-list { width: 240px; flex-basis: 240px; }
|
.project-list { width: 240px; flex-basis: 240px; }
|
||||||
|
.project-workspace { padding: 20px; }
|
||||||
|
.project-stage-bar { padding-right: 18px; padding-left: 18px; }
|
||||||
|
.stage-copy small { display: none; }
|
||||||
.settings-page { padding-left: 28px; padding-right: 28px; }
|
.settings-page { padding-left: 28px; padding-right: 28px; }
|
||||||
.settings-grid { grid-template-columns: 310px 1fr; }
|
.settings-grid { grid-template-columns: 310px 1fr; }
|
||||||
.skill-grid { grid-template-columns: 390px 1fr; }
|
.skill-grid { grid-template-columns: 390px 1fr; }
|
||||||
@@ -226,9 +287,14 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; }
|
.rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; }
|
||||||
.rail a { width: 52px; font-size: 12px; }
|
.rail a { width: 52px; font-size: 12px; }
|
||||||
.project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; }
|
.project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; }
|
||||||
|
.project-workspace { padding: 12px; }
|
||||||
|
.workspace-main { border-radius: 7px; }
|
||||||
|
.project-stage-bar { grid-template-columns: repeat(4, minmax(112px, 1fr)); overflow-x: auto; padding: 16px; }
|
||||||
|
.project-stage:not(:last-child)::after { right: 8px; }
|
||||||
.work-scroll { width: calc(100% - 32px); }
|
.work-scroll { width: calc(100% - 32px); }
|
||||||
.page-header { padding: 0 16px; }
|
.page-header { padding: 0 16px; }
|
||||||
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
|
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
|
||||||
|
.run-actions { gap: 2px; }
|
||||||
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
|
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
|
||||||
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
|
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
|
||||||
.flow-tool::before, .flow-reasoning::before { left: 7px; }
|
.flow-tool::before, .flow-reasoning::before { left: 7px; }
|
||||||
@@ -236,10 +302,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.material-item { grid-template-columns: 1fr 160px; }
|
.material-item { grid-template-columns: 1fr 160px; }
|
||||||
.material-upload { grid-column: 2; }
|
.material-upload { grid-column: 2; }
|
||||||
.settings-page { padding: 24px 16px; }
|
.settings-page { padding: 24px 16px; }
|
||||||
|
.settings-header { align-items: center; }
|
||||||
.settings-grid, .skill-grid { grid-template-columns: 1fr; }
|
.settings-grid, .skill-grid { grid-template-columns: 1fr; }
|
||||||
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
|
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
|
||||||
.settings-form, .skill-detail { padding: 24px 0 0; }
|
.settings-form, .skill-detail { padding: 24px 0 0; }
|
||||||
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
|
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
|
||||||
|
.model-actions { flex-wrap: wrap; }
|
||||||
|
.model-danger-actions { width: 100%; margin-left: 0; padding-top: 6px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 15173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8080'
|
'/api': 'http://127.0.0.1:8080'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user