Compare commits
4 Commits
99d16bb3b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d392436620 | ||
|
|
8c3883e356 | ||
|
|
4615df3f90 | ||
|
|
595c8da595 |
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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@ __pycache__/
|
||||
!.env.example
|
||||
deepseek_key.txt
|
||||
test_data/
|
||||
|
||||
# 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。
|
||||
deploy/*.tar
|
||||
|
||||
68
README.md
68
README.md
@@ -11,14 +11,14 @@
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
docker compose --profile build-only build agent-runtime
|
||||
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox
|
||||
docker compose up -d postgres
|
||||
mvn -q -f server/pom.xml spring-boot:run
|
||||
npm --prefix client install
|
||||
npm --prefix client run dev
|
||||
npm --prefix web-ui install
|
||||
npm --prefix web-ui run dev
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123`。
|
||||
本地开发服务器默认使用 Vite 配置的端口。容器化部署固定从 <http://127.0.0.1:5173> 访问。
|
||||
|
||||
模型连接只能在“模型配置”页面新增并持久化到 PostgreSQL;API Key 会使用 `APP_MASTER_KEY`
|
||||
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
|
||||
@@ -29,12 +29,68 @@ npm --prefix client run dev
|
||||
$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
|
||||
```
|
||||
|
||||
## Docker Compose 部署
|
||||
|
||||
部署包含 Nginx 前端、Spring Boot 后端、Agent Runtime 镜像和 PostgreSQL。Agent Runtime
|
||||
不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `agent-runtime` 服务只校验
|
||||
镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。
|
||||
|
||||
1. 在开发机构建并导出 Agent Runtime 镜像:
|
||||
|
||||
```bash
|
||||
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox
|
||||
docker save -o smart-factory-agent-runtime-0.1.0.tar smart-factory-agent-runtime:0.1.0
|
||||
```
|
||||
|
||||
将 Tar 文件传到服务器后导入。Compose 设置了 `pull_policy: never`,若镜像不存在会直接报错,
|
||||
不会在服务器自动拉取或构建:
|
||||
|
||||
```bash
|
||||
docker load -i smart-factory-agent-runtime-0.1.0.tar
|
||||
docker image inspect smart-factory-agent-runtime:0.1.0
|
||||
```
|
||||
|
||||
2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. 确认 `MANUAGENT_DATA_ROOT` 是 Docker 宿主机上的绝对路径。这个路径会以完全相同的路径挂载到
|
||||
后端容器,供动态 Agent Runtime 继续挂载项目材料、工作目录和产物。Linux 默认值为
|
||||
`/srv/manuagent/data`。
|
||||
|
||||
4. 构建前后端并启动完整服务:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
docker compose -f docker-compose.yml ps
|
||||
```
|
||||
|
||||
5. 打开 <http://127.0.0.1:5173>,使用 `.env` 中的 `APP_ADMIN_USERNAME` 和
|
||||
`APP_ADMIN_PASSWORD` 登录。查看日志或停止服务:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml logs -f backend
|
||||
docker compose -f docker-compose.yml down
|
||||
```
|
||||
|
||||
默认端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173,可仅在本机
|
||||
验证时临时执行 `$env:FRONTEND_PORT = '15173'`;Linux 服务器部署应保留默认的 5173。
|
||||
|
||||
PostgreSQL 数据保存在 `postgres-data` 命名卷中;项目材料、Agent 工作区、快照和生成文件保存在
|
||||
`MANUAGENT_DATA_ROOT`。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除
|
||||
PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的
|
||||
Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将
|
||||
`SESSION_COOKIE_SECURE` 设为 `true`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
mvn -q -f server/pom.xml test
|
||||
npm --prefix client run test -- --run
|
||||
npm --prefix client run build
|
||||
npm --prefix web-ui run test
|
||||
npm --prefix web-ui run build
|
||||
docker compose -f docker-compose.yml config --quiet
|
||||
```
|
||||
|
||||
产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。
|
||||
|
||||
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:
|
||||
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 "$@"
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent } from '../api'
|
||||
import ProjectPage from './ProjectPage.vue'
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -139,7 +140,7 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
|
||||
throw new Error(`未处理的测试请求:${url}`)
|
||||
})
|
||||
|
||||
return shallowMount(ProjectPage, {
|
||||
const wrapper = shallowMount(ProjectPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'el-button': ElButtonStub,
|
||||
@@ -151,8 +152,16 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
|
||||
}
|
||||
}
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
|
||||
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
|
||||
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
|
||||
})
|
||||
|
||||
describe('ProjectPage 模型切换', () => {
|
||||
beforeEach(() => {
|
||||
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
|
||||
@@ -280,12 +289,112 @@ describe('ProjectPage 项目工作区', () => {
|
||||
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 })
|
||||
@@ -294,6 +403,17 @@ describe('ProjectPage 项目工作区', () => {
|
||||
|
||||
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!([{
|
||||
|
||||
@@ -62,8 +62,17 @@ let loadVersion = 0
|
||||
let previousScrollY = 0
|
||||
let hasObservedScrollPosition = false
|
||||
let pendingFollowScrollTarget: number | null = null
|
||||
type UserScrollIntent = 'none' | 'up' | 'down'
|
||||
let userScrollIntent: UserScrollIntent = 'none'
|
||||
let touchStartY: number | null = null
|
||||
let scrollbarDragLastY: number | null = null
|
||||
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
|
||||
let scrollbarDragActive = false
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const LATEST_OUTPUT_THRESHOLD = 48
|
||||
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
|
||||
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
|
||||
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
|
||||
|
||||
const projectId = computed(() => String(route.params.id || ''))
|
||||
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
||||
@@ -131,6 +140,11 @@ async function load(id: string) {
|
||||
previousScrollY = window.scrollY
|
||||
hasObservedScrollPosition = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
scrollbarDragLastY = null
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = false
|
||||
stopStream?.()
|
||||
stopStream = null
|
||||
project.value = null
|
||||
@@ -480,17 +494,20 @@ function updateScrollState() {
|
||||
const completingFollowScroll = pendingFollowScrollTarget !== null
|
||||
|
||||
if (completingFollowScroll) {
|
||||
// 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。
|
||||
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD
|
||||
if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
// 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
|
||||
// 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
|
||||
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
if (reachedRequestedTarget) {
|
||||
pendingFollowScrollTarget = null
|
||||
followsLatestOutput.value = true
|
||||
}
|
||||
} else if (scrollingUp) {
|
||||
} else if (scrollingUp && userScrollIntent !== 'down') {
|
||||
followsLatestOutput.value = false
|
||||
} else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
userScrollIntent = 'up'
|
||||
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
|
||||
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
previousScrollY = currentScrollY
|
||||
hasObservedScrollPosition = true
|
||||
@@ -506,7 +523,9 @@ function updateScrollState() {
|
||||
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
|
||||
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= LATEST_OUTPUT_THRESHOLD
|
||||
// 所有程序滚动(箭头点击、事件更新、ResizeObserver)统一只在真正抵达目标时
|
||||
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
|
||||
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
? null
|
||||
: targetScrollY
|
||||
hasObservedScrollPosition = true
|
||||
@@ -518,9 +537,137 @@ function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
||||
function scrollToBottom() {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
|
||||
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
|
||||
moveViewportToLatestOutput('auto')
|
||||
}
|
||||
|
||||
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null
|
||||
return Boolean(element && (
|
||||
(element instanceof HTMLElement && element.isContentEditable)
|
||||
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
|
||||
))
|
||||
}
|
||||
|
||||
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
|
||||
function restoreFollowIfAtLatest() {
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
|
||||
*
|
||||
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
|
||||
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
|
||||
*/
|
||||
function handleUserWheel(event: WheelEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.deltaY !== 0) {
|
||||
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
|
||||
// 事件可能在用户上滚后再次把页面状态恢复到底部。
|
||||
pendingFollowScrollTarget = null
|
||||
}
|
||||
if (event.deltaY < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.deltaY > 0) {
|
||||
userScrollIntent = 'down'
|
||||
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
|
||||
function handleUserScrollbarDrag(event: MouseEvent) {
|
||||
const scrollbarStart = document.documentElement.clientWidth
|
||||
if (event.clientX >= scrollbarStart) {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
scrollbarDragLastY = event.clientY
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = true
|
||||
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
|
||||
userScrollIntent = 'up'
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
|
||||
function handleUserScrollbarMove(event: MouseEvent) {
|
||||
if (!scrollbarDragActive || scrollbarDragLastY === null) return
|
||||
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
|
||||
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
|
||||
scrollbarDragLastY = event.clientY
|
||||
}
|
||||
|
||||
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
|
||||
function handleUserMouseUp() {
|
||||
if (!scrollbarDragActive) return
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
scrollbarDragActive = false
|
||||
scrollbarDragLastY = null
|
||||
const draggedUp = scrollbarDragDirection === 'up'
|
||||
scrollbarDragDirection = 'none'
|
||||
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
|
||||
function handleUserTouchStart(event: TouchEvent) {
|
||||
touchStartY = event.touches[0]?.clientY ?? null
|
||||
}
|
||||
|
||||
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
|
||||
function handleUserTouchMove(event: TouchEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
pendingFollowScrollTarget = null
|
||||
const currentY = event.touches[0]?.clientY
|
||||
if (touchStartY === null || currentY === undefined) {
|
||||
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
return
|
||||
}
|
||||
const pageScrollDelta = touchStartY - currentY
|
||||
if (pageScrollDelta < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (pageScrollDelta > 0) {
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
|
||||
function handleUserTouchEnd() {
|
||||
touchStartY = null
|
||||
}
|
||||
|
||||
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
|
||||
function handleUserKeydown(event: KeyboardEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||
watch(() => events.value.length, async () => {
|
||||
await nextTick()
|
||||
@@ -543,6 +690,15 @@ onMounted(() => {
|
||||
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()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
@@ -551,6 +707,15 @@ onBeforeUnmount(() => {
|
||||
outputResizeObserver?.disconnect()
|
||||
outputResizeObserver = null
|
||||
window.removeEventListener('scroll', updateScrollState)
|
||||
window.removeEventListener('wheel', handleUserWheel)
|
||||
window.removeEventListener('mousedown', handleUserScrollbarDrag)
|
||||
window.removeEventListener('mousemove', handleUserScrollbarMove)
|
||||
window.removeEventListener('mouseup', handleUserMouseUp)
|
||||
window.removeEventListener('touchstart', handleUserTouchStart)
|
||||
window.removeEventListener('touchmove', handleUserTouchMove)
|
||||
window.removeEventListener('touchend', handleUserTouchEnd)
|
||||
window.removeEventListener('touchcancel', handleUserTouchEnd)
|
||||
window.removeEventListener('keydown', handleUserKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user