Compare commits

...

4 Commits

Author SHA1 Message Date
Zhu Junhao
d392436620 构建:补充离线部署配置并排除镜像包 2026-09-05 10:37:43 +08:00
Zhu Junhao
8c3883e356 修复:完善流式输出滚动跟随 2026-09-05 10:37:43 +08:00
Zhu Junhao
4615df3f90 构建:改用预构建的 Agent Runtime 镜像 2026-09-04 15:45:23 +08:00
Zhu Junhao
595c8da595 构建:新增完整容器化部署 2026-09-04 15:31:17 +08:00
16 changed files with 797 additions and 39 deletions

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# 复制为 .env 后必须替换以下三个敏感值;不要把真实 .env 提交到 Git。
POSTGRES_PASSWORD=replace-with-a-strong-database-password
APP_MASTER_KEY=replace-with-at-least-32-random-characters
APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password
# 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。
DASHSCOPE_API_KEY=
# 非敏感运行参数。
POSTGRES_DB=smart_factory_agent
POSTGRES_USER=smart_factory
APP_ADMIN_USERNAME=admin
APP_RUN_TIMEOUT=60m
SESSION_COOKIE_SECURE=false
MANUAGENT_DATA_ROOT=/srv/manuagent/data
# 默认值即部署要求的 0.0.0.0:5173普通 Linux 部署无需修改。
FRONTEND_PORT=5173
# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。
AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0
BACKEND_IMAGE=manuagent-backend:0.1.0
FRONTEND_IMAGE=manuagent-frontend:0.1.0

6
.gitattributes vendored Normal file
View File

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

3
.gitignore vendored
View File

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

View File

@@ -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> 访问
模型连接只能在“模型配置”页面新增并持久化到 PostgreSQLAPI 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 种子迁移写入数据库,无需额外导入文件。

View File

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

22
deploy/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# 复制为 .env 后必须替换以下三个敏感值;不要把真实 .env 提交到 Git。
POSTGRES_PASSWORD=replace-with-a-strong-database-password
APP_MASTER_KEY=replace-with-at-least-32-random-characters
APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password
# 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。
DASHSCOPE_API_KEY=
# 非敏感运行参数。
POSTGRES_DB=smart_factory_agent
POSTGRES_USER=smart_factory
APP_ADMIN_USERNAME=admin
APP_RUN_TIMEOUT=60m
SESSION_COOKIE_SECURE=false
MANUAGENT_DATA_ROOT=/srv/manuagent/data
# 默认值即部署要求的 0.0.0.0:5173普通 Linux 部署无需修改。
FRONTEND_PORT=5173
# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。
AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0
BACKEND_IMAGE=manuagent-backend:0.1.0
FRONTEND_IMAGE=manuagent-frontend:0.1.0

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

@@ -0,0 +1,130 @@
name: manuagent
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent}
POSTGRES_USER: ${POSTGRES_USER:-smart_factory}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- manuagent-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
# 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。
# Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。
# 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。
agent-runtime:
image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0}
pull_policy: never
command: ["/bin/true"]
restart: "no"
networks:
- manuagent-network
backend:
image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0}
build:
context: ./server
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
restart: true
agent-runtime:
condition: service_completed_successfully
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory}
# Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin}
APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0}
APP_SANDBOX_NETWORK: manuagent-network
APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m}
SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
secrets:
- source: postgres_password
target: spring.datasource.password
- source: app_master_key
target: app.master-key
- source: admin_password
target: app.admin-password
- source: dashscope_api_key
target: dashscope_key
volumes:
# DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。
- type: bind
source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
# AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
expose:
- "8080"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/api/auth/csrf"]
interval: 10s
timeout: 5s
retries: 18
start_period: 30s
stop_grace_period: 30s
frontend:
image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0}
build:
context: ./web-ui
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
backend:
condition: service_healthy
restart: true
ports:
# 默认严格监听 0.0.0.0:5173仅当宿主机保留该端口时才通过 FRONTEND_PORT 临时覆盖。
- "0.0.0.0:${FRONTEND_PORT:-5173}:80"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
networks:
manuagent-network:
# 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。
name: manuagent-network
driver: bridge
volumes:
postgres-data:
secrets:
postgres_password:
environment: POSTGRES_PASSWORD
app_master_key:
environment: APP_MASTER_KEY
admin_password:
environment: APP_ADMIN_PASSWORD
dashscope_api_key:
environment: DASHSCOPE_API_KEY

130
docker-compose.yml Normal file
View File

@@ -0,0 +1,130 @@
name: manuagent
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent}
POSTGRES_USER: ${POSTGRES_USER:-smart_factory}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- manuagent-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
# 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。
# Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。
# 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。
agent-runtime:
image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0}
pull_policy: never
command: ["/bin/true"]
restart: "no"
networks:
- manuagent-network
backend:
image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0}
build:
context: ./server
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
restart: true
agent-runtime:
condition: service_completed_successfully
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory}
# Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin}
APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0}
APP_SANDBOX_NETWORK: manuagent-network
APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m}
SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
secrets:
- source: postgres_password
target: spring.datasource.password
- source: app_master_key
target: app.master-key
- source: admin_password
target: app.admin-password
- source: dashscope_api_key
target: dashscope_key
volumes:
# DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。
- type: bind
source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
# AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
expose:
- "8080"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/api/auth/csrf"]
interval: 10s
timeout: 5s
retries: 18
start_period: 30s
stop_grace_period: 30s
frontend:
image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0}
build:
context: ./web-ui
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
backend:
condition: service_healthy
restart: true
ports:
# 默认严格监听 0.0.0.0:5173仅当宿主机保留该端口时才通过 FRONTEND_PORT 临时覆盖。
- "0.0.0.0:${FRONTEND_PORT:-5173}:80"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
networks:
manuagent-network:
# 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。
name: manuagent-network
driver: bridge
volumes:
postgres-data:
secrets:
postgres_password:
environment: POSTGRES_PASSWORD
app_master_key:
environment: APP_MASTER_KEY
admin_password:
environment: APP_ADMIN_PASSWORD
dashscope_api_key:
environment: DASHSCOPE_API_KEY

6
server/.dockerignore Normal file
View File

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

37
server/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# Maven 构建阶段使用与项目一致的 JDK 21并利用 BuildKit 缓存减少重复下载依赖的时间。
FROM maven:3.9.13-eclipse-temurin-21 AS builder
WORKDIR /workspace
COPY pom.xml ./
COPY src ./src
# 直接打包只解析项目真正需要的依赖;独立 go-offline 会额外下载大量未参与构建的报告插件。
RUN --mount=type=cache,target=/root/.m2 mvn -B -DskipTests package
# AgentScope DockerSandbox 通过 docker 命令创建 Runtime直接复用官方镜像中的 CLI 二进制。
FROM docker:29-cli AS docker-cli
FROM eclipse-temurin:21-jre-jammy
# curl 用于容器健康检查gosu 用于完成目录和 Docker Socket 权限初始化后降权运行 Java。
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
RUN groupadd --gid 10001 manuagent \
&& useradd --uid 10001 --gid 10001 --create-home --shell /bin/bash manuagent \
&& mkdir -p /opt/manuagent /srv/manuagent/data \
&& chown -R manuagent:manuagent /opt/manuagent /srv/manuagent
WORKDIR /opt/manuagent
COPY --from=builder /workspace/target/*.jar app.jar
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["java", "-XX:MaxRAMPercentage=75.0", "-Djava.security.egd=file:/dev/urandom", "-jar", "/opt/manuagent/app.jar"]

View File

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

7
web-ui/.dockerignore Normal file
View File

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

21
web-ui/Dockerfile Normal file
View File

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

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

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

View File

@@ -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!([{

View File

@@ -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>